Structured Concurrency in JDK 27: How StructuredTaskScope Prevents Thread Leaks
Virtual threads solved the scalability problem — but not the correctness problem. You can now spin up a million threads cheaply, which is exactly why it matters more than ever to guarantee that none of them outlive the operation that created them. Structured Concurrency, now in its seventh preview as JEP 533 targeted for JDK 27, is the missing piece. This article focuses on the mechanics: how StructuredTaskScope enforces lifetime boundaries, what thread leaks look like without it, and when to reach for each of the two primary Joiner policies.
JCG has previously covered how virtual threads compare to Project Reactor’s WebFlux and to Go’s goroutines. Those articles were about throughput and programming model. This one is about something different: what happens to your threads when things go wrong, and how Structured Concurrency makes that outcome predictable and verifiable rather than accidental.
The Problem That Structured Concurrency Actually Solves
To understand why StructuredTaskScope exists, it helps to look at what breaks without it. Consider a typical scenario: a service endpoint fans out three concurrent calls — a user lookup, an inventory check, and a pricing query — and needs all three results to compose a response. With ExecutorService and Future, a naive implementation looks something like this:
The unstructured approach — can leak threads silently
// Do NOT ship this pattern in production ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor(); Future<User> userFuture = exec.submit(() -> userService.fetch(userId)); Future<Inventory> invFuture = exec.submit(() -> inventory.check(sku)); Future<Price> priceFuture = exec.submit(() -> pricing.quote(sku, userId)); User user = userFuture.get(); // throws if user fetch fails Inventory inv = invFuture.get(); // <-- invFuture and priceFuture Price price = priceFuture.get(); // keep running. Nobody told them to stop.
If userFuture.get() throws, the inventory and pricing threads are still running — blocked on remote calls, consuming resources, holding open connections. They will eventually complete or time out on their own terms. In a high-throughput service, those orphaned threads accumulate. Under load or during a dependency outage, this pattern is how you end up with hundreds of stuck virtual threads, exhausted connection pools, and a thread dump that tells you nothing useful about which request spawned which work.
This is a thread leak — and it is entirely invisible to the calling code. The compiler cannot catch it. There is no exception. The work just continues silently past its useful life.

ExecutorService, a failing subtask leaves siblings running (leak). Inside a StructuredTaskScope, the scope cancels siblings and guarantees all threads are terminated before close() returns.What StructuredTaskScope Guarantees
Structured Concurrency enforces a single, powerful invariant: no thread forked inside a scope can outlive the scope itself. This guarantee holds regardless of whether subtasks succeed, fail, or are cancelled. All of the subtasks’ threads are guaranteed to have terminated once the scope is closed, and no thread is left behind when the block exits. That is not a convention or a best practice — it is enforced at the JVM level by the scope’s lifecycle.
Additionally, attempts to call a fork method from a thread that is not the scope’s owner will fail with an exception. Using a scope outside of a try-with-resources block and returning without calling close(), or without maintaining the proper nesting of close() calls, may cause the scope’s methods to throw a StructureViolationException. The API is deliberately designed so that the correct usage pattern is the only easy one to follow.
Observability bonus: JEP 505 extended the JSON thread-dump format added for virtual threads to show how StructuredTaskScopes group threads into a hierarchy.
Running jcmd <pid> Thread.dump_to_file -format=json <file> produces a structured view of which scope owns which threads — making it straightforward to diagnose which request a thread belongs to, something that was nearly impossible with unstructured concurrency.
The API in JDK 27: What Changed and Why
The API has evolved significantly across its seven preview iterations. Understanding the progression helps you read older examples without confusion and appreciate why the current design looks the way it does.
JDK 21–24
Initial preview shape: StructuredTaskScope with two built-in subclasses — ShutdownOnFailure and ShutdownOnSuccess. Public constructors, policy baked into the subclass.
JDK 25
Major redesign (JEP 505). Public constructors replaced with static open() factory methods. ShutdownOnFailure / ShutdownOnSuccess removed. Policy and result type now supplied via a composable Joiner interface — separating what to do on completion from how to collect results.
JDK 26
JEP 525. New Joiner.onTimeout() callback. allSuccessfulOrThrow() now returns List<T> instead of a stream. anySuccessfulResultOrThrow() renamed to anySuccessfulOrThrow().
JDK 27
JEP 533 (current). The StructuredTaskScope and Joiner interface now carry a third type parameter, R_X, representing the exception type that join() can throw.
Standard joiners (allSuccessfulOrThrow(), anySuccessfulOrThrow()) now throw ExecutionException instead of the preview-only FailedException. New open(UnaryOperator) overload for configuration (name, timeout, thread factory) without a custom Joiner.
The net result after JDK 27 is an API that is cleaner and safer: for library authors writing custom joiners, the throws clause becomes part of the type rather than something the implementation declares separately. That makes signatures more honest and gives callers a precise checked-exception contract on join(). For application code using the built-in joiners via open(), the compiler infers everything — source code looks the same as before.
The Basic Shape: Open, Fork, Join, Close
Every StructuredTaskScope usage follows the same four-step lifecycle, enforced by try-with-resources. Before looking at the two primary Joiner patterns, it helps to see the skeleton clearly:
StructuredTaskScope — lifecycle skeleton (JDK 26/27 API)
// Enable with: --enable-preview on javac and java
try (var scope = StructuredTaskScope.open(joiner, config)) {
Subtask<User> userTask = scope.fork(() -> userService.fetch(userId));
Subtask<Inventory> invTask = scope.fork(() -> inventory.check(sku));
Subtask<Price> priceTask = scope.fork(() -> pricing.quote(sku, userId));
scope.join(); // blocks until joiner policy is satisfied
// Subtask.get() is safe here: all threads have completed
return new ProductResponse(userTask.get(), invTask.get(), priceTask.get());
} // scope.close() — all threads guaranteed terminated
Notice that Subtask.get() is only called after join(). This is the key discipline: you never inspect a subtask’s result while it might still be running. The join() call is the synchronisation point, and close() is the hard guarantee. Even if you somehow exit the try block early (via an exception, a return, or a break), close() cancels any still-running subtasks and waits for them to terminate before unwinding the stack.
Pattern 1 — Shutdown on Failure (All Must Succeed)
The most common pattern in service fan-out scenarios is: fork N subtasks, require all of them to succeed, and fail fast if any one of them fails. This maps directly to Joiner.allSuccessfulOrThrow() — which is also what the zero-argument StructuredTaskScope.open() uses as its default policy.
Fan-out requiring all results — allSuccessfulOrThrow (JDK 27)
record ProductPage(User user, Inventory inv, Price price) {}
ProductPage buildProductPage(String userId, String sku)
throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(
Joiner.allSuccessfulOrThrow(),
cfg -> cfg.withTimeout(Duration.ofSeconds(3)))) {
Subtask<User> userTask = scope.fork(() -> userService.fetch(userId));
Subtask<Inventory> invTask = scope.fork(() -> inventory.check(sku));
Subtask<Price> priceTask = scope.fork(() -> pricing.quote(sku, userId));
List<Object> results = scope.join(); // throws ExecutionException if any subtask fails
// throws if timeout fires (JDK 26+)
return new ProductPage(
userTask.get(),
invTask.get(),
priceTask.get());
}
// All three threads are guaranteed terminated here.
// If pricing failed, user and inventory were interrupted by the scope.
}
The behaviour when any subtask fails is: the scope is cancelled, all other running subtasks receive an interrupt signal, join() throws ExecutionException wrapping the original cause (in JDK 27; it was FailedException in earlier previews), and close() waits for the remaining threads to acknowledge the interrupt and exit. By the time the exception propagates to the caller, there are zero orphaned threads.
JDK 27 catch clause change: In JDK 21–26 you caught StructuredTaskScope.FailedException. From JDK 27 (JEP 533) the standard joiners throw java.util.concurrent.ExecutionException instead — the same type as Future.get(). The original exception is still accessible via getCause(). Update your catch clauses accordingly if you are migrating from an earlier preview.
Pattern 2 — Shutdown on Success (First Wins)
The second primary pattern is the mirror image: fork N subtasks performing the same logical operation (perhaps against different backends or replicas), and take the result of whichever one succeeds first. This is the hedged-request or fallback pattern. Joiner.anySuccessfulOrThrow() handles it:
Hedged requests — anySuccessfulOrThrow (JDK 27)
String fetchWithFallback(String key)
throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(
Joiner.anySuccessfulOrThrow())) { // first success wins
scope.fork(() -> primaryCache.get(key)); // fast path
scope.fork(() -> secondaryCache.get(key)); // warm standby
scope.fork(() -> database.fetch(key)); // authoritative source
return scope.join(); // returns first non-null result
// throws ExecutionException if ALL fail
}
// As soon as one subtask succeeds, the scope cancels the remaining two.
// No database thread outlives the call if the cache hit first.
}
The moment one subtask completes successfully, the scope signals the other two to stop. In practice this means a successful primary cache hit cancels both the secondary cache lookup and the database call before they waste time on a round trip that is no longer needed. Again — no leaks, no manual cancellation bookkeeping.
If all subtasks fail, join() throws ExecutionException with one of the failures as the cause (implementation-defined which one). This gives you a clean checked-exception contract to handle at the call site.
Joiner Policy Comparison — When to Use Each
Joiner API at a Glance
| Joiner factory method | Cancels scope when… | join() returns | Typical use case |
|---|---|---|---|
Joiner.allSuccessfulOrThrow() | Any subtask fails | List<T> of results | Fan-out requiring all results (default) |
Joiner.anySuccessfulOrThrow() | Any subtask succeeds | T — the first result | Hedged requests, fallback chains |
Joiner.awaitAllSuccessfulOrThrow() | Any subtask fails | void (Runnable subtasks) | Fire-and-forget fan-out with error gate |
Custom Joiner implementation | Your logic in onComplete() | Your result() type | Partial success, quorum, custom policies |
JDK 26 addition —
onTimeout(): When you configure a scope with.withTimeout(Duration), a customJoinercan implementonTimeout()to return a fallback result rather than throwing when the deadline fires. This is particularly useful for non-critical enrichment calls — for example, fetching personalisation data that enhances but is not required for the response.
Nested Scopes and Scope Inheritance
One of the more powerful features of StructuredTaskScope is that scopes nest naturally. A subtask can create its own StructuredTaskScope to fork its own subtasks, thus creating a hierarchy of scopes. That hierarchy is reflected in the code’s block structure, which confines the lifetimes of the subtasks.
This means a subtask in an outer scope can itself become the owner of an inner scope — and the inner scope’s threads are guaranteed to finish before the inner scope closes, which in turn guarantees they finish before the outer scope closes. The lifetime guarantee is transitive. You can build arbitrarily deep fan-out graphs with full leak prevention, simply by nesting try-with-resources blocks.
Scoped Values also propagate through this hierarchy automatically. If you bind a trace ID or a request context via ScopedValue (JEP 506, finalised in JDK 25), every subtask forked anywhere in the scope hierarchy reads the same value without any explicit thread-local copying.
static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
Response handleRequest(String requestId, String sku)
throws InterruptedException, ExecutionException {
return ScopedValue.where(TRACE_ID, requestId).call(() -> {
try (var outerScope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
Subtask<User> userTask = outerScope.fork(() -> {
// TRACE_ID is visible here — same value as the request thread
log.debug("Fetching user, trace={}", TRACE_ID.get());
// This subtask owns its own inner scope
try (var inner = StructuredTaskScope.open(Joiner.anySuccessfulOrThrow())) {
inner.fork(() -> userCache.get(userId));
inner.fork(() -> userDB.fetch(userId));
return inner.join(); // first of cache/DB wins
}
});
Subtask<Price> priceTask = outerScope.fork(() -> pricing.quote(sku));
outerScope.join();
return new Response(userTask.get(), priceTask.get());
}
});
}
Thread Leak Proof: What the JVM Actually Enforces
It is worth being precise about what “enforced” means here, because the guarantee is stronger than most developers initially expect. If a scope’s block exits before joining, the scope is cancelled and the owner will wait in the close() method for all subtasks to terminate before the close() method itself returns.
In other words: even if you throw an exception inside the try block before calling join(), the try-with-resources machinery calls close(), which cancels the scope and blocks until all forked threads acknowledge the cancellation and exit. The calling thread will wait — there is no escape hatch that allows threads to outlive the scope. This is fundamentally different from ExecutorService.shutdownNow(), which is best-effort and returns a list of tasks that could not be stopped.
The safety guarantee is transitive only if all concurrent work is forked into the scope. If a subtask internally spawns an unmanaged CompletableFuture that escapes the scope, that future is invisible to the scope’s lifecycle management — and you have re-introduced exactly the thread-leak problem you were solving. Structured Concurrency and unstructured concurrency must not be mixed within the same logical operation.
What Structured Concurrency Is Not
Just as important as knowing when to use StructuredTaskScope is knowing when not to. The API is deliberately narrow in scope — and that narrowness is a feature, not a limitation.
| You want… | Use instead |
|---|---|
| Streaming data between threads with back-pressure | Channels (not yet in JDK; out of scope for JEP 533 explicitly) |
Composable async pipelines with operators (map, flatMap) | CompletableFuture chains or Project Reactor |
| Tasks with no meaningful parent-child relationship | ExecutorService / virtual thread executor |
| Library API returning futures for callers to compose | CompletableFuture — callers need to control composition |
| Fan-out requiring all results + cancellation + no leak | StructuredTaskScope — this is exactly its sweet spot |
Structured Concurrency Preview Journey — JDK 19 → 27
Enabling Preview Features
Since Structured Concurrency remains a preview feature through JDK 27, you must opt in explicitly at both compile and runtime. For Maven users:
pom.xml — enable preview for JDK 27
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>27</release>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>
And from the command line:
Compile and run with preview enabled
javac --release 27 --enable-preview ProductService.java java --enable-preview ProductService
Production caveat: Preview APIs are not subject to the same backward compatibility guarantees as finalised APIs. JEP 533 is targeted for JDK 27 with the expectation that finalization is expected before end of 2026
— but until the feature graduates, avoid shipping preview-dependent code in long-lived production services without a clear upgrade path.
What We Learned
Structured Concurrency addresses the correctness gap that virtual threads left open: virtual threads make threads cheap, but without scope enforcement they still leak when subtasks outlive the operations that forked them. StructuredTaskScope closes that gap with a single unconditional guarantee — no forked thread survives close() — enforced at the JVM level, not by convention.
We walked through why the unstructured ExecutorService pattern leaks silently, how the scope’s try-with-resources lifecycle prevents this structurally, and how the two primary Joiner policies map to the two fundamental fan-out patterns: allSuccessfulOrThrow() for all-or-nothing fan-out and anySuccessfulOrThrow() for hedged requests. We also covered the JDK 27 (JEP 533) changes — the new R_X type parameter for checked-exception precision and the switch from FailedException to ExecutionException — alongside nested scope composition and ScopedValue propagation. The API has iterated through seven previews and is widely expected to finalise by end of 2026. There has never been a better moment to understand it deeply.



