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 with exceptionally(), 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()
        );
    }
}
Tip: 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();
    }
}
Note: 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

FeatureCompletableFutureStructuredTaskScopeForkJoinPool
Auto cancellation on failureNo (manual)YesNo
Scope-bound task lifetimeNoYesNo
Error propagationWrapped in ExecutionExceptionDirect exception propagationWrapped
Timeout supportorTimeout() / completeOnTimeout()joinUntil(Instant)Manual
Debuggability (thread dumps)Poor (anonymous threads)Good (structured hierarchy)Moderate
Virtual thread integrationRequires custom executorBuilt-inSeparate concern
Learning curveHigh (chains, combinators)Low (sequential-style code)High (work-stealing details)
Best forAsync pipelines, reactive-adjacentFan-out/fan-in, race patternsCPU-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 StructuredTaskScope in 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. StructuredTaskScope provides 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.