Structured Concurrency in Java: Why It’s Better Than CompletableFuture — and What It Still Can’t Do
Six previews in, the API is nearly stable. But the community debate about whether it truly replaces CompletableFuture — or just makes virtual threads easier to misuse — is still live. This article draws the line clearly.
ompletableFuture is not broken. It is, however, an API designed for a world where threads were expensive, concurrency was a performance optimisation, and correctness was the developer’s problem to solve alone. Virtual threads changed the premise. Structured Concurrency changes the contract.
Java’s Structured Concurrency API has been in preview since JDK 21 and received its most significant redesign in JDK 25 via JEP 505. As of today — JDK 26 — it is in a sixth preview via JEP 525 with only minor refinements remaining. The core model is stable. Finalization is the expected next step. This is therefore the right time to understand not just what the API does, but where it stops and where you still need the old tools.
The Problem CompletableFuture Could Never Cleanly Solve
To appreciate what Structured Concurrency offers, you first have to feel the specific pain it was designed to address. Consider the most common concurrency pattern in a microservice: two independent I/O calls that must both complete before you can return a response. With CompletableFuture, the code looks reasonable:
CompletableFuture approach — looks fine, has hidden traps
Response handle() throws ExecutionException, InterruptedException {
Future<String> user = executor.submit(() -> findUser());
Future<Integer> order = executor.submit(() -> fetchOrder());
String theUser = user.get();
int theOrder = order.get();
return new Response(theUser, theOrder);
}
The problems here are subtle but real. If findUser() throws, fetchOrder() keeps running — consuming threads, connections, and compute — with no owner left to receive its result. If the calling thread is interrupted while blocked on user.get(), the interruption is not propagated to either submitted task. And if you look at a thread dump, the two worker threads appear completely unrelated to the thread that started them. The logical hierarchy of the task — “I need both of these to answer this request” — is invisible to the runtime.
The JEP specification describes this directly: “the need to manually coordinate lifetimes is due to the fact that ExecutorService and Future allow unrestricted patterns of concurrency. There are no constraints upon, or ordering of, any of the threads involved.” Structured Concurrency addresses all three issues with one structural guarantee: subtasks cannot outlive their scope.
What Structured Concurrency Actually Guarantees
The guarantee is precisely analogous to how method calls work on a single thread. When a method calls another method, the callee cannot outlive the caller — it must return (or throw) before the stack frame unwinds. Structured Concurrency brings that same lifetime discipline to concurrent code: every subtask forked within a StructuredTaskScope is guaranteed to have terminated — one way or another — before the scope closes.
Here is the same fan-out pattern rewritten using the JDK 25 / JDK 26 API:
Structured Concurrency — JDK 25/26 (JEP 505 / JEP 525)
Response handle() throws ExecutionException, InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // waits for both; cancels on first failure
return new Response(user.get(), order.get());
}
}
The difference is not just syntactic. Three concrete guarantees apply here that the CompletableFuture version could not make. First, if findUser() fails, the scope is immediately cancelled and fetchOrder() receives an interrupt — no thread leak. Second, if the parent thread is interrupted, the interruption propagates to all forked subtasks. Third, by the time scope.join() returns, all subtasks are done — either successfully, having thrown, or having been cancelled. The scope’s lifetime and the subtasks’ lifetimes are identical.
JDK 25 redesign: The most important change in JEP 505 was replacing public constructors with static factory methods (
StructuredTaskScope.open()) and introducing theJoinerinterface to separate completion policy from scope management. You no longer subclassStructuredTaskScopeto implement custom policies — you pass aJoinerimplementation toopen(). This is a deliberate “composition over inheritance” pivot that significantly improves flexibility and testability.
| Aspect | Before JDK 25 pre-JEP 505 | After JDK 25 JEP 505 |
|---|---|---|
| Instantiation | Public constructors called directly — new StructuredTaskScope() or new ShutdownOnFailure() | Static factory method — StructuredTaskScope.open() with an optional Joiner and Config |
| Custom policy | Subclass StructuredTaskScope and override handleComplete() — inheritance required | Implement the Joiner interface and pass it to open() — composition, no subclassing |
| Completion hook | handleComplete(Subtask) on the subclass — tightly coupled to the scope type | Joiner.onComplete(Subtask) — decoupled, independently testable, reusable across scopes |
| Result production | Override throwIfFailed() or call methods on the subclass after join() | Joiner.result() called automatically by join() — single, uniform exit point |
| Cancellation trigger | Built into each subclass (ShutdownOnFailure, ShutdownOnSuccess) — not extensible without subclassing | onComplete() returns boolean — return true to cancel the scope; any joiner can implement any policy |
| Timeout handling | joinUntil(deadline) always threw TimeoutException — no custom timeout behaviour possible | Joiner.onTimeout() hook (JDK 26 addition) — joiner decides whether to throw, return partial results, or use a default |
| Design principle | Inheritance — policy is baked into the class hierarchy | Composition — policy is a parameter; scope lifecycle and completion policy are separate concerns |
| Testability | Testing a custom policy required instantiating a concrete subclass in test context | Joiner is a plain interface — mock or stub independently of any scope |
The Joiner Model: Completion Policies as First-Class Objects
The Joiner interface is the most important new concept introduced in JDK 25, and understanding it is the key to writing non-trivial agents with the API. A Joiner has three methods: onFork() (called when a subtask is forked), onComplete() (called when a subtask finishes, either successfully or with a failure), and result() (called by join() to produce the scope’s final outcome). The onComplete() method returns a boolean — if it returns true, the scope is cancelled immediately.
The JDK ships three built-in joiners covering the most common cases. Joiner.allSuccessfulOrThrow() is the default: wait for all subtasks, fail the scope if any subtask fails (returns a List of results in JDK 26, upgraded from a Stream in JDK 25). Joiner.anySuccessfulOrThrow() — renamed from anySuccessfulResultOrThrow() in JDK 26 — cancels the scope the moment any subtask succeeds, useful for hedging patterns. Joiner.allUntil(Predicate) lets you supply a custom cancellation condition.
Writing a custom Joiner for partial results
One of the clearest demonstrations of why the Joiner model is powerful is the partial-results pattern — collect whatever subtasks succeed within a timeout, return what you have. Before JDK 25, this required subclassing; now it is a clean implementation of a single interface:
Custom Joiner with onTimeout — JDK 26 (JEP 525)
class PartialResultsJoiner<T> implements StructuredTaskScope.Joiner<T, List<T>> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(StructuredTaskScope.Subtask<T> subtask) {
if (subtask.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false; // never cancel early — collect until timeout
}
@Override
public void onTimeout() {
// JDK 26 addition: called when the scope's configured timeout fires
// instead of throwing TimeoutException, we just stop collecting
}
@Override
public List<T> result() {
return List.copyOf(results);
}
}
Prior to JDK 26, a timed-out scope.join() always threw TimeoutException immediately. The new onTimeout() hook means a custom joiner can now decide what “timed out” means for its specific policy — return partial results, return a default, or still throw. As InfoQ noted in January 2026, this allows custom joiners to determine how structured concurrent work completes under time constraints rather than always propagating a hard exception.
Error Propagation: Where Structured Concurrency Wins Decisively
Error propagation is the area where Structured Concurrency most clearly surpasses both CompletableFuture and raw ExecutorService. The problem with the old models is that errors arrive at the wrong place, in the wrong form, and at the wrong time.
Structured Concurrency- Failure in any subtask
Immediately cancels the scope. All other subtasks receive an interrupt. The exception is unwrapped and re-thrown from join() as the original exception type, not wrapped in ExecutionException. The parent thread sees the failure immediately.
CompletableFuture- Failure in any stage
The failing stage completes exceptionally. Other stages keep running unless you manually wire cancellation. When you call .get(), you get an ExecutionException wrapping the original cause, forcing getCause() on every error path.
Concretely, if findUser() throws a checked UserNotFoundException, the old code requires:
try {
String user = future.get();
} catch (ExecutionException e) {
if (e.getCause() instanceof UserNotFoundException unfe) {
// finally reach the actual exception
}
}
With Structured Concurrency the exception propagates directly. You can use pattern matching on the actual exception type at the call site — no unwrapping, no getCause() chain. As JEP 505 explicitly notes, exception-handling code can use instanceof with pattern matching directly on the exception thrown by join(), because the exception is not wrapped.
Thread dump observability: Both JEP 505 and JEP 525 extend the JSON thread dump format (available via
jcmd <pid> Thread.dump_to_file -format=json <file>) to show howStructuredTaskScopes group their forked threads into a hierarchy. In production, you can see exactly which subtasks belong to which scope — something that was completely invisible withExecutorService.
Capability coverage: Structured Concurrency vs CompletableFuture vs ExecutorService

What CompletableFuture Still Does Better
Being clear about this matters because the JEPs themselves are clear about it. JEP 525 states explicitly: “It is not a goal to replace any of the concurrency constructs in the java.util.concurrent package, such as ExecutorService or Future.” That is not diplomatic boilerplate — it reflects a genuine architectural boundary. There are things CompletableFuture does that Structured Concurrency was not designed to replace.
Non-blocking composition across asynchronous stages
Structured Concurrency is blocking by design. The owner thread blocks in join() until all subtasks complete. CompletableFuture, by contrast, is genuinely non-blocking: you can chain .thenApply(), .thenCompose(), and .thenCombine() callbacks without blocking any thread at all, and the composition happens on whatever thread completes the stage. For reactive pipelines, event-driven architectures, or any system where the goal is maximum thread utilisation with minimal blocking, CompletableFuture remains the right choice.
Dynamic fan-out with unknown cardinality
Structured Concurrency works best when the set of subtasks is known before the scope opens. You can fork tasks dynamically inside a scope — there is no restriction — but the pattern becomes awkward when the number of tasks depends on results produced by earlier tasks in the same scope. Deeply recursive fan-out, or pipelines where each stage produces a variable number of follow-up tasks, often fit more naturally into a CompletableFuture chain or a proper dataflow library than into a scope.
Long-lived, detached background work
By definition, Structured Concurrency cannot model work that outlives its scope. Scheduled background jobs, event listeners, daemon threads, or any task that should survive the originating call are explicitly out of scope — the lifetime constraint is a feature, not a limitation, for the core use case, but it means you still need an ExecutorService for work that genuinely belongs in the background.
The misuse risk: Because virtual threads make blocking cheap, it is tempting to write deeply nested
StructuredTaskScopehierarchies — scopes inside scopes inside scopes — that model complex orchestration in a blocking style. This is syntactically valid but architecturally fragile. The blocking model means that if any scope in the hierarchy stalls, everything above it stalls too. For orchestration patterns involving partial progress, streaming results, or back-pressure, a reactive or dataflow model is more honest about what is happening.
When to migrate from CompletableFuture to Structured Concurrency (score 0–10)

The Open Questions: What the API Still Leaves Unanswered
Six previews in, the API is mature enough to use seriously. That is exactly why the remaining gaps deserve a frank discussion rather than an optimistic hand-wave toward future JEPs.
No channels or data streaming between threads
JEP 525 states clearly: “It is not a goal to define a means of sharing streams of data among threads (i.e., channels). We might propose to do so in the future.” This is a significant absence. Communicating Sequential Processes (CSP), Go’s goroutines, Kotlin’s coroutines with Channel, and Erlang’s actor model all have a primitive for streaming data between concurrent workers. Java has BlockingQueue (which works but is clumsy), reactive streams (which are powerful but non-trivial), and nothing native for the virtual-thread era that feels as clean as the scope model does for fan-out.
No new cancellation mechanism
Cancellation in Structured Concurrency still relies on thread interruption — the same mechanism Java has had since the beginning. This is explicitly acknowledged by the JEPs: “It is not a goal to replace the existing thread interruption mechanism with a new thread cancellation mechanism. We might propose to do so in the future.” Thread interruption is cooperative and easy to accidentally swallow. A cleaner, structured cancellation token — analogous to CancellationToken in .NET or Kotlin’s Job hierarchy — would make the concurrency model more robust, but it does not exist yet.
Still a preview feature in JDK 26
The API requires --enable-preview to use in JDK 26. That is a real deployment constraint for teams on JDK 25 (the LTS), which ships it as a fifth preview. Finalization is widely expected in JDK 27 or shortly after, but until then, any production code using the API is coupled to a specific JDK version and a feature that is technically subject to change. For most new greenfield services, that is an acceptable risk. For long-lived production systems running on JDK 25 LTS, it may not be.
Scoped Values are finalized: One important piece of the Loom puzzle that is stable is
ScopedValue, finalized in JDK 25 via JEP 506. Scoped values are the recommended replacement forThreadLocalin virtual-thread code, and they integrate directly withStructuredTaskScope— subtasks inherit the scoped value bindings of their parent scope automatically. If you are adopting Structured Concurrency, adopting Scoped Values at the same time makes the model coherent.
Decision Matrix: Which API for Which Pattern
| Concurrency pattern | Best API | Reason |
|---|---|---|
| Fan-out and collect (N tasks → 1 result) | StructuredTaskScope | Scope lifetime = task lifetime. Clean error propagation. Thread dump observability. |
| First result wins (hedging / racing) | Joiner.anySuccessfulOrThrow() | Built-in cancellation of losing tasks on first success. No boilerplate. |
| Non-blocking async pipeline | CompletableFuture | No thread blocked between stages. thenCompose / thenCombine compose without blocking. |
| Partial results under timeout | Custom Joiner + onTimeout() | JDK 26 onTimeout() makes this clean. Previously required workarounds. |
| Reactive stream processing | Reactor / RxJava | Back-pressure, windowing, operators — nothing in JDK covers this natively. |
| Long-lived background work | ExecutorService | Work that must outlive the originating call cannot be in a scope by design. |
| Passing context across threads | ScopedValue (finalized JDK 25) | Automatic inheritance across fork. Replaces ThreadLocal for virtual thread code. |
| Recursive parallel decomposition | ForkJoinPool | Work-stealing scheduler designed for this. StructuredTaskScope does not work-steal. |
| Thread-per-request server handler | StructuredTaskScope | Natural fit: request = scope, subtasks = downstream calls. Lifetime alignment perfect. |
Getting Started: Enabling Preview in JDK 26
Because Structured Concurrency is still a preview feature, you must opt in explicitly. The mechanism varies slightly by build tool, but the principle is the same: pass --enable-preview at both compile time and runtime, and target JDK 26.
Gradle (Kotlin DSL)
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("--enable-preview"))
sourceCompatibility = "26"
targetCompatibility = "26"
}
tasks.withType<Test>().configureEach {
jvmArgs("--enable-preview")
}
tasks.withType<JavaExec>().configureEach {
jvmArgs("--enable-preview")
}
Maven (pom.xml)
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>26</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>
JDK 25 LTS note: JDK 25 is the current LTS and ships Structured Concurrency as JEP 505 — preview 5. The API is nearly identical to JDK 26, with the key differences being that
Joiner.onTimeout()does not exist yet andallSuccessfulOrThrow()returns aStreaminstead of aList. Code written for JDK 25 will require small adjustments to run on JDK 26 preview. Plan for this migration when the API finalizes.
What We Have Learned
Structured Concurrency does not replace CompletableFuture — and the OpenJDK team says so explicitly. What it does replace is the unsafe, leak-prone pattern of submitting independent tasks to an ExecutorService and hoping that cancellation, error propagation, and thread lifetime happen to work out. After six previews, the core model is stable and coherent: scopes give subtasks a clear lifetime, the Joiner abstraction makes completion policies composable rather than inherited, and ScopedValues (finalized in JDK 25) complete the context-passing picture.
The JDK 25 redesign — replacing constructors with static factory methods and introducing the Joiner interface — was the most important evolution, and the JDK 26 additions are genuinely minor. The gaps that remain are real: no channel primitive for streaming between threads, no new cancellation mechanism beyond thread interruption, and a still-preview status that constrains production adoption on the LTS. For teams writing new request-scoped concurrent logic — service fan-outs, parallel I/O, hedging calls — Structured Concurrency is the right API today.
For reactive pipelines, background work, and non-blocking composition, CompletableFuture and the reactive ecosystem remain the right tools. Knowing which is which is the actual skill.

