Core Java

Structured Concurrency and the Death of CompletableFuture Hell

What Java 21–26’s concurrency model actually changes at the architecture level. Not a feature tour — a genuine examination of the tree-of-lifetimes model, which bugs become structurally impossible, and when the old tools still belong.

The actual problem with CompletableFuture

CompletableFuture is not broken. It was a significant improvement over raw Future when Java 8 shipped it in 2014, and a lot of real, working code runs on it today. The problem isn’t a bug in the API — it’s a design philosophy that made sense when threads were expensive and concurrency was a performance optimization, but that creates structural fragility when you ask it to coordinate the kind of fine-grained concurrent work that modern services routinely need.

The core issue, as the JEP 505 spec states directly, is that ExecutorService and Future allow unrestricted patterns of concurrency. One thread can create the executor, a second can submit work, a third can await results. A subtask started by one task can return its result to a completely different task — or to nobody. There is no enforced relationship between parent and child work, even when that relationship obviously exists in the logic of your program.

That freedom is precisely what generates the pathological failure modes. Consider the classic fanout-and-aggregate pattern — fetch a user record and their orders concurrently, then combine them. With CompletableFuture, you submit both tasks independently. If fetchUser() fails, fetchOrders() keeps running — in its own thread, consuming resources, possibly mutating state, long after the containing request has failed and moved on. JEP 453 names this directly: “This is a thread leak which, at best, wastes resources; at worst, the fetchOrder() thread will interfere with other tasks.”

Furthermore, if the thread executing the parent is interrupted — say, because the HTTP request timed out — that interruption does not propagate to the subtasks. Both child threads leak, continuing to run even after the parent has given up. And if one subtask fails before another even finishes starting, the parent waits unnecessarily on the in-progress one rather than cancelling it. Every one of these failure modes requires you, the developer, to write explicit coordination logic. And most of the time, in most codebases, nobody does — because it’s subtle, it only shows up under failure, and CompletableFuture‘s API doesn’t nudge you toward writing it.

The compounding problem:

The thread leak problem gets dramatically worse at scale. In a service handling thousands of concurrent requests, leaked threads from upstream failures accumulate quietly. They don’t cause obvious errors — they cause latency degradation, OOM pressure, and subtle state corruption that appears as a “load-related” bug in post-mortems. This pattern shows up in real production incidents across the Java ecosystem, from Keycloak’s Infinispan integration to Azure SDK’s service bus client.

The canonical leaky pattern

// CompletableFuture: three separate failure modes hiding here
UserProfile handle(long requestId) throws Exception {
    // Two independently-submitted tasks with no parent-child relationship
    CompletableFuture<User> userFuture =
        CompletableFuture.supplyAsync(() -> findUser(requestId));
    CompletableFuture<List<Order>> orderFuture =
        CompletableFuture.supplyAsync(() -> fetchOrders(requestId));

    // Bug 1: if findUser() throws, fetchOrders() keeps running (thread leak)
    User user = userFuture.get();

    // Bug 2: if THIS thread is interrupted, neither subtask receives interrupt
    List<Order> orders = orderFuture.get();

    // Bug 3: if fetchOrders() fails first, we still block waiting for findUser()
    return new UserProfile(user, orders);
}

The tree-of-lifetimes model: what it actually means

Structured concurrency’s core idea is deceptively simple: a task must not outlive the scope that created it. This is exactly analogous to structured programming’s insistence that a function cannot outlive its caller. The insight, as articulated in the JEP, is that this analogy with the call stack is not cosmetic — it’s the same invariant applied to concurrency. Just as the JVM’s call stack is a tree of method invocations, a structured concurrent program has a runtime tree of task invocations. And just as you can read a stack trace and understand exactly what path of calls led to the current state, you can read a structured concurrent program and understand exactly which tasks are running and why.

In the StructuredTaskScope model, you open a scope in a try-with-resources block, fork subtasks inside it, join them, then close. The JVM guarantees that by the time the closing brace is reached, every forked subtask has terminated — whether successfully, by throwing, or by being cancelled. There are no stragglers. There are no tasks floating free somewhere in an executor’s queue with no logical connection to any live request. The code’s structure reflects the concurrency’s structure.

Critically, the JDK 25 redesign (JEP 505) replaced subclassing with a Joiner interface. Previously, you had to subclass StructuredTaskScope to implement custom completion policies. Now, you pass a Joiner implementation to StructuredTaskScope.open(). The built-in joiners cover the two most common cases: Joiner.allSuccessfulOrThrow() waits for all subtasks to succeed (failing fast if any throws), and Joiner.anySuccessfulOrThrow() returns as soon as one succeeds and cancels the rest. Custom policies are composable without inheritance.

The structured equivalent — three guarantees in the type contract

// StructuredTaskScope (JDK 25+ API with static factory methods)
UserProfile handle(long requestId) throws Exception {
    try (var scope = StructuredTaskScope.open()) {   // starts a lifetime boundary
        // Both subtasks are forked into this scope — they are children of it
        Subtask<User>       userTask  = scope.fork(() -> findUser(requestId));
        Subtask<List<Order>> orderTask = scope.fork(() -> fetchOrders(requestId));

        scope.join();  // waits for all; cancel propagates on failure

        // Guarantee 1: if findUser() threw, fetchOrders() was interrupted — no leak
        // Guarantee 2: if this thread is interrupted, BOTH subtasks receive it
        // Guarantee 3: by the time we reach here, all threads have terminated
        return new UserProfile(userTask.get(), orderTask.get());
    }
    // Closing brace: zero threads outstanding, resources deterministically released
}

The semantic shift

The difference isn’t just syntactic convenience. The CompletableFuture version makes a correctness guarantee you have to write yourself and can silently omit. The StructuredTaskScope version makes correctness guarantees that the runtime enforces regardless of what you write inside. The invariant is in the type contract, not the developer’s discipline.

Bugs that become structurally impossible

This is the most important section, and also the most frequently misunderstood. Structured concurrency does not eliminate all concurrency bugs. It eliminates a specific, well-defined class of them by encoding their prevention into the execution model itself. Understanding which class is which matters for evaluating how much it actually changes the reliability story.

The structural impossibility of the first four categories is what makes this a genuine model change rather than a convenience API. Thread leaks and cancellation failures are among the most common sources of latency spikes and resource exhaustion in production Java services. Eliminating them from the class of possible mistakes — not reducing their frequency, but making them unwritable — changes how you have to think about service reliability.

What changes at the architecture level

Here’s where the article gets genuinely interesting, because most discussions of structured concurrency stop at the API and never ask what it implies for how services should be decomposed. There are three implications worth naming explicitly.

Concurrency structure mirrors code structure — and that constrains decomposition

In a CompletableFuture-based system, the concurrency topology can be completely disconnected from the code topology. A CompletableFuture created in a controller can be composed with one created in a repository layer, joined by a third party in a service layer, and observed by a monitoring callback registered in yet another module. The flow of concurrent work and the flow of code execution are orthogonal. This gives you flexibility — and it gives you the footgun.

Structured concurrency enforces that the code structure is the concurrency structure. A scope opened in function A can only be joined in function A. Subtasks forked in A’s scope cannot escape it. This means that the boundaries of concurrent work are identical to the boundaries of function scope. As a consequence, service methods become natural units of concurrent work: a method fans out into subtasks, joins them, and returns a result. The concurrency is local to the method. This is actually a benefit for service decomposition — it pushes you toward methods that are complete, self-contained units of orchestration rather than partial pipelines that hand off futures to other layers.

Fan-out-and-aggregate is the natural fit; streaming is not

The model excels at the pattern that dominates microservice backends: call multiple downstream services in parallel, aggregate results, return. This is precisely the pattern StructuredTaskScope was designed for, and it handles it with zero boilerplate and strong safety guarantees. Retailer product pages, financial dashboard aggregations, healthcare summary endpoints — any read that fans out to multiple services and waits for all of them maps perfectly onto a scope with Joiner.allSuccessfulOrThrow().

However, as Java Code Geeks’ analysis notes, streaming and backpressure scenarios are a poor fit. If you need to produce results incrementally as each subtask completes — rather than batching all results at the end of a join — structured concurrency doesn’t give you a channel primitive (explicitly not a goal of JEP 505). You’d reach for reactive streams, or an explicit blocking queue with a consumer thread, or Kotlin coroutines’ Flow. The model’s blocking-centric design means that if any scope stalls, everything above it stalls too.

Observability becomes a first-class property of the runtime

This is perhaps the most underrated architectural implication. Because structured concurrency builds a runtime tree of tasks that mirrors the code’s logical hierarchy, thread dumps become readable in a way they never were before. In a CompletableFuture-based system, a thread dump under load shows you dozens of threads doing various things, with no way to tell which HTTP request they belong to or whether they’re still relevant. In a structured concurrent system, the task hierarchy is real and queriable. A thread dump shows a tree: request handler → fanout scope → [fetch user subtask, fetch orders subtask]. You can see, at a glance, which parent owns which child and what state each is in.

The observability bet

The OpenJDK team has consistently cited observability improvement as a primary motivation, equal to correctness. When virtual threads deliver abundant concurrency and structured concurrency provides the runtime hierarchy to make sense of it, the combination promises that debugging highly concurrent Java services becomes as tractable as debugging single-threaded ones — because the call tree is just there, in the thread dump.

Scoped Values: the missing piece for context propagation

No discussion of structured concurrency’s architecture implications is complete without covering Scoped Values, which were finalized in JDK 25 via JEP 506. The two features were designed together and complete each other.

The problem they solve is context propagation across threads. In a traditional ThreadLocal-based system, request-scoped context (user identity, trace ID, security context, transaction boundaries) is stored in a ThreadLocal variable. When you submit work to an executor, the new thread doesn’t inherit that context unless you manually copy it — and with virtual threads and structured concurrency forking potentially many subtasks, that copying becomes both expensive and error-prone. The ThreadLocal context loss bug in Reactor-Core is a real example of what happens when context doesn’t propagate across async boundaries.

Scoped Values address this with a fundamentally different model: an immutable value bound to a lexical scope, automatically inherited by all child threads created via StructuredTaskScope.fork(). There’s no copying — child threads read the binding directly with zero overhead. And because the value is immutable, there’s no risk of a subtask corrupting the context for its siblings. When the scope closes, the binding disappears. No remove() calls, no memory leaks from forgotten cleanup.

Scoped Values replacing ThreadLocal for request context — JDK 25

// Define once (equivalent to ThreadLocal, but immutable + scoped)
static final ScopedValue<RequestContext> REQUEST_CTX = ScopedValue.newInstance();

// In the request handler — bind context for the duration of this request
void handleRequest(Request req, Response res) {
    var ctx = RequestContext.from(req);   // user, trace-id, security principal

    ScopedValue.runWhere(REQUEST_CTX, ctx, () -> {
        // Inside here, REQUEST_CTX is bound for this thread AND all child threads

        try (var scope = StructuredTaskScope.open()) {
            // These subtasks inherit REQUEST_CTX automatically — no copying
            var userTask  = scope.fork(() -> userService.fetch(REQUEST_CTX.get().userId()));
            var auditTask = scope.fork(() -> auditLog.record(REQUEST_CTX.get().traceId()));

            scope.join();
            res.send(userTask.get());
        }
    });
    // Binding gone here — no cleanup needed, no ThreadLocal.remove() to forget
}

From an architecture standpoint, this changes how services handle cross-cutting concerns. Traditionally, passing a trace ID or security context through a call stack required either method parameter propagation (which is verbose and invasive) or ThreadLocal variables (which leak in thread-pool-based execution). Scoped Values provide a clean, structured alternative: bind the context at the request boundary, and every operation within the scope — including all forked subtasks — reads the same immutable context without any wiring.

When CompletableFuture still belongs

The OpenJDK team is explicit: it is not a goal of JEP 505 to replace CompletableFuture or ExecutorService. That’s not diplomatic hedging — it reflects a genuine design philosophy. These APIs solve different problems, and conflating them makes both worse.

CompletableFuture remains the right tool when tasks are genuinely independent — when there is no semantic parent-child relationship between them, when you want to compose asynchronous pipelines functionally, when you need fine-grained control over which executor runs each stage, or when you’re building library code that returns futures to callers who will compose them in ways you don’t control. Its callback-chaining API (thenApplythenCombineexceptionally) is expressive for declarative pipeline composition in a way that blocking structured concurrency code simply isn’t.

There are also practical constraints. Structured concurrency is still a preview API in JDK 26. Most production Java services run on Java 21 LTS, where enabling preview features is a real operational decision. Many teams will wait for finalization — likely JDK 27 or 28 — before adopting it in production. In the meantime, CompletableFuture combined with virtual threads (which are final as of JDK 21) already eliminates most of the thread-pool exhaustion problems that plagued earlier Java concurrency patterns.

SituationCompletableFutureStructuredTaskScope
Fan out + aggregate (all must succeed)Manual cancellation wiringNatural fit; built-in guarantee
Race to first successShutdownOnSuccess workaroundJoiner.anySuccessfulOrThrow()
Functional pipeline compositionthenApply / thenCombine / etc.Verbose in blocking style
Long-lived background tasksExecutorService + CFScopes tied to single operation
Request-scoped contextManual ThreadLocal copyScopedValues auto-inherited
Streaming with back-pressureReactive / Flow APINo channel primitive yet
Thread dump readabilityOpaque — tasks have no parentRuntime task tree visible
Preview flag required (Java 21 LTS)Production-stable–enable-preview needed
Cancellation on sibling failureManual + error-proneAutomatic on scope close
Compute-intensive parallel workForkJoinPool / parallel streamsVirtual threads; per-task overhead

When to reach for StructuredTaskScope

The honest answer to “when should I migrate to structured concurrency?” is: when the safety guarantees matter more than the preview flag concern, and when your concurrency pattern is fan-out-and-aggregate rather than pipeline or long-lived. The following decision framework is practical, not aspirational.

// reach for StructuredTaskScope when:

  • You’re writing a new service on JDK 25+ and can enable preview features in your build
  • The logic fans out to multiple downstream calls that all must succeed
  • Thread leaks from cancellation failures are a known production pain point
  • You need request-scoped context to flow transparently to all subtasks
  • Observability matters — you want meaningful thread dumps under load
  • The method is the natural boundary: all concurrent work starts and ends here

// keep CompletableFuture / ExecutorService when:

  • You’re on Java 21 LTS and the preview flag is not acceptable in your environment
  • The tasks are truly independent with no semantic parent-child relationship
  • You need functional pipeline composition across multiple service layers
  • The work is long-lived (background jobs, scheduled polling, cache warmers)
  • You need streaming with back-pressure — channels aren’t in JEP 505
  • You’re writing a library API that returns futures to be composed by callers

The mixing trap

One anti-pattern worth naming explicitly: mixing StructuredTaskScope and raw CompletableFuture in the same logical operation. If a forked subtask internally creates an unmanaged CompletableFuture that escapes the scope — by completing it on a different thread after the scope closes — you’ve reintroduced exactly the lifetime management problem you were trying to eliminate. The safety guarantees of structured concurrency are transitive only if all concurrent work inside the scope is forked into it.

The JEP history: why six previews?

It’s worth addressing the elephant in the room: after six preview iterations spanning JDK 19 through 26, a lot of developers have reasonably asked whether structured concurrency is ever going to finalize. The extended preview period has eroded trust in the API’s stability, even though the core model has been conceptually stable since JDK 21.

JDK 19

JEP 428 — Incubator (1st)

Initial incubation. fork() returned a Future, which proved to be the wrong abstraction — Future‘s API was a distraction from the structured use case.

JDK 21

JEP 453 — 1st Preview

fork() changed to return Subtask rather than Future. This was the most important single change — Subtask::get() enforces post-join semantics, while Future::get() didn’t.

JDK 22–24

JEP 462 / 480 / 499 — Previews 2–4 (no change)

Three re-previews without API changes, accumulating real-world feedback. The primary question was whether the subclassing model for custom policies was the right design. Spoiler: it wasn’t.

JDK 25

JEP 505 — 5th Preview (most significant redesign)

Public constructors replaced with static factory methods (StructuredTaskScope.open()). Subclassing replaced with the Joiner interface for custom completion policies. This is the API shape that will almost certainly finalize.

JDK 26

JEP 525 — 6th Preview (minor changes)

Joiner.onTimeout() added. allSuccessfulOrThrow() now returns a list instead of a stream. Method renamed: anySuccessfulResultOrThrow() → anySuccessfulOrThrow(). Core model unchanged.

The extended preview period reflects the OpenJDK community’s genuine caution about finalizing concurrency primitives — once stable, they’re part of the platform forever. The JDK 25 redesign (Joiner-based factory methods instead of subclassing) was a real improvement, not a cosmetic one, and it was worth the extra preview cycle. Java Code Geeks’ analysis describes the current state as “the API is mature enough to use seriously” — and that’s the right framing. The model is stable. The finalization is a question of completeness, not correctness.

What we have learned

Structured concurrency is not a replacement for CompletableFuture. It is a replacement for the pattern of submitting related subtasks to an executor and hoping that cancellation, lifetime management, and error propagation happen to work out. The OpenJDK team says this explicitly, and the design reflects it.

What the tree-of-lifetimes model actually changes at the architecture level is this: the scope of concurrent work becomes identical to the scope of code execution. A task cannot outlive the block that created it. The runtime enforces this — it is not a convention you establish through discipline and discipline alone. As a direct consequence, four serious classes of production bugs (thread leaks on failure, missed cancellation propagation, premature result observation, and ExecutorService lifecycle mismanagement) become structurally unwritable rather than merely hard to trigger.

For service decomposition, this pushes naturally toward methods as complete units of orchestration: methods that fan out, join, and return. The fan-out-and-aggregate pattern — which dominates backend service code — fits the model perfectly. Streaming with back-pressure, long-lived background work, and functional pipeline composition over independent tasks all still belong to CompletableFuture and reactive frameworks. Combined with ScopedValues (finalized in JDK 25), the context propagation story for request-scoped data finally has a clean answer that doesn’t rely on manually copying ThreadLocal state across executor boundaries. The six-preview journey has been long, but the model that emerged from JDK 25 is coherent, and JDK 26’s changes are genuinely minor. Finalization is when, not if.

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