Core Java

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 the Joiner interface to separate completion policy from scope management. You no longer subclass StructuredTaskScope to implement custom policies — you pass a Joiner implementation to open(). This is a deliberate “composition over inheritance” pivot that significantly improves flexibility and testability.

AspectBefore JDK 25 pre-JEP 505After JDK 25 JEP 505
InstantiationPublic constructors called directly — new StructuredTaskScope() or new ShutdownOnFailure()Static factory method — StructuredTaskScope.open() with an optional Joiner and Config
Custom policySubclass StructuredTaskScope and override handleComplete() — inheritance requiredImplement the Joiner interface and pass it to open() — composition, no subclassing
Completion hookhandleComplete(Subtask) on the subclass — tightly coupled to the scope typeJoiner.onComplete(Subtask) — decoupled, independently testable, reusable across scopes
Result productionOverride throwIfFailed() or call methods on the subclass after join()Joiner.result() called automatically by join() — single, uniform exit point
Cancellation triggerBuilt into each subclass (ShutdownOnFailureShutdownOnSuccess) — not extensible without subclassingonComplete() returns boolean — return true to cancel the scope; any joiner can implement any policy
Timeout handlingjoinUntil(deadline) always threw TimeoutException — no custom timeout behaviour possibleJoiner.onTimeout() hook (JDK 26 addition) — joiner decides whether to throw, return partial results, or use a default
Design principleInheritance — policy is baked into the class hierarchyComposition — policy is a parameter; scope lifecycle and completion policy are separate concerns
TestabilityTesting a custom policy required instantiating a concrete subclass in test contextJoiner 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 how StructuredTaskScopes group their forked threads into a hierarchy. In production, you can see exactly which subtasks belong to which scope — something that was completely invisible with ExecutorService.

Capability coverage: Structured Concurrency vs CompletableFuture vs ExecutorService

Source: JEP 525 (openjdk.org) · JEP 505 (openjdk.org) · author analysis

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 StructuredTaskScope hierarchies — 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 for ThreadLocal in virtual-thread code, and they integrate directly with StructuredTaskScope — 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 patternBest APIReason
Fan-out and collect (N tasks → 1 result)StructuredTaskScopeScope 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 pipelineCompletableFutureNo thread blocked between stages. thenCompose / thenCombine compose without blocking.
Partial results under timeoutCustom Joiner + onTimeout()JDK 26 onTimeout() makes this clean. Previously required workarounds.
Reactive stream processingReactor / RxJavaBack-pressure, windowing, operators — nothing in JDK covers this natively.
Long-lived background workExecutorServiceWork that must outlive the originating call cannot be in a scope by design.
Passing context across threadsScopedValue (finalized JDK 25)Automatic inheritance across fork. Replaces ThreadLocal for virtual thread code.
Recursive parallel decompositionForkJoinPoolWork-stealing scheduler designed for this. StructuredTaskScope does not work-steal.
Thread-per-request server handlerStructuredTaskScopeNatural 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 and allSuccessfulOrThrow() returns a Stream instead of a List. 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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button