← Back to the section

The classic pain of parallel code: a method launched three subtasks, one failed — and the other two keep burning resources with nobody to cancel them. Or the reverse: the method returned, but an "orphaned" subtask is still alive, writing to the log a minute after the client got the response. Structured concurrency solves this with one principle: the lifetime of subtasks is bounded by the block of code that spawned them. In Java this principle is embodied by StructuredTaskScope.

no scope method handle() method boundary fetchUser ✗ fetchOrders method returned alive, still logging in a scope scope scope boundary fetchUser ✗ fetchOrderscancelled exit: none alive

Top — no scope: the method left with an error, and the second subtask outlived it and keeps working. Bottom — the same pair inside a scope: the first failure cancels the rest, and nobody crosses the boundary of the block.

The problem: unstructured fan-out

Let's assemble a user profile from two sources with an ExecutorService:

Future<User> userF = executor.submit(() -> fetchUser(id));
Future<List<Order>> ordersF = executor.submit(() -> fetchOrders(id));
User user = userF.get();          // and if fetchOrders already failed?
List<Order> orders = ordersF.get(); // we only find out here

Three chronic diseases live here. Leaked work: if fetchUser threw, get() propagates the exception — but nobody cancelled fetchOrders, and it runs to completion for nothing. Lost errors: had fetchOrders failed first, we wouldn't know until we reach its get(). A severed link: the call stack doesn't show these tasks are children of our method; the parent thread and the subtasks live separate lives, and cancelling the parent doesn't touch the children.

CompletableFuture with allOf smooths over part of this, but cancelling the "siblings" on the first failure and tying tasks firmly to the spawning block still has to be assembled by hand.

The idea: concurrency with block structure

The analogy: control flow once jumped anywhere via goto; structured programming introduced blocks — enter at the top, exit at the bottom — and code became readable. Structured concurrency does the same for threads: subtasks live strictly inside a scope, and you cannot leave the scope until every subtask has finished — by success, failure, or cancellation.

The guarantees follow automatically: no subtask outlives its parent; errors are collected in one place; cancellation propagates top-down.

The same boundary is visible on the plain API, with no preview flags:

live example

import java.util.concurrent.*;

public class ScopeDemo {
    static String user() { throw new IllegalStateException("no connection"); }

    static String orders() throws InterruptedException {
        Thread.sleep(200);
        System.out.println("  fetchOrders finished its useless work");
        return "orders";
    }

    public static void main(String[] args) throws Exception {
        ExecutorService loose = Executors.newVirtualThreadPerTaskExecutor();
        Future<String> f = loose.submit(ScopeDemo::user);
        loose.submit(ScopeDemo::orders);
        try { f.get(); } catch (ExecutionException e) { System.out.println("no scope: we left with an error"); }
        Thread.sleep(400);

        try (ExecutorService scope = Executors.newVirtualThreadPerTaskExecutor()) {
            var done = new ExecutorCompletionService<String>(scope);
            Future<String> a = done.submit(ScopeDemo::user);
            Future<String> b = done.submit(ScopeDemo::orders);
            try { done.take().get(); } catch (ExecutionException e) {
                System.out.println("in a scope: failure — cancelling the rest");
                a.cancel(true);
                b.cancel(true);
            }
        }
        System.out.println("left the block: no subtask alive");
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The first half left a subtask writing into the void. The second cancelled the rest on the very first failure and did not release control until they were done: StructuredTaskScope gives exactly that without assembling it by hand.

StructuredTaskScope: the Java 25 API

Response handle(long id) throws InterruptedException {
    try (var scope = StructuredTaskScope.<Object>open()) {
        Subtask<User> user = scope.fork(() -> fetchUser(id));
        Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));

        scope.join();   // wait for all; on any failure — cancel the rest and throw

        return new Response(user.get(), orders.get());
    } // leaving the try guarantees: no live subtasks remain
}

The breakdown:

  • open() creates the scope; try-with-resources guarantees cleanup — however the block ends, all subtasks are awaited or cancelled.
  • fork(...) launches a subtask on its own virtual thread and returns a Subtask — a "claim ticket" for the result.
  • join() is the single waiting point. The default policy: all succeed — carry on; any one fails — the rest are cancelled, and join() throws a FailedException with the cause inside.
  • subtask.get() after join simply picks up the finished value — no blocking involved.

Leaked work, lost errors, and orphaned tasks disappear not through discipline but by construction: there is nowhere left to express them.

Joiner: waiting policies

What counts as the scope's success is configured via a Joiner:

// First successful result — cancel the rest (racing mirrors/replicas):
try (var scope = StructuredTaskScope.open(
        Joiner.<String>anySuccessfulResultOrThrow())) {
    scope.fork(() -> fetchFrom(mirrorA));
    scope.fork(() -> fetchFrom(mirrorB));
    return scope.join();   // returns the winner's result itself
}

// Wait for ALL and sort the outcomes out by hand:
try (var scope = StructuredTaskScope.open(Joiner.<Widget>awaitAll())) { ... }

Plus scope configuration — for example, a shared deadline:

StructuredTaskScope.open(Joiner.allSuccessfulOrThrow(),
        cf -> cf.withTimeout(Duration.ofSeconds(2)));
// didn't make it — all subtasks are cancelled, join() throws TimeoutException

A timeout on the group as a whole — what took a pile of workarounds with futures is one line here.

One more detail: ScopedValue bindings are inherited by subtasks automatically — request context (user, trace id) flows into the fan-out with no manual plumbing.

Status and practice

StructuredTaskScope is a preview feature (JEP 505, fifth preview in Java 25): it requires the --enable-preview flag, and the API has changed before — in Java 21–24, instead of open() there were the ShutdownOnFailure/ShutdownOnSuccess classes (still found in codebases from those years). The idea is stable; check the signatures against your JDK.

Today: in libraries and code that lives for years — carefully; in services on a fresh JDK — already a candidate wherever people write CompletableFuture.allOf or a batch of submit/get.

In short

  • Unstructured fan-out problems: leaked work, lost errors, orphaned subtasks, hand-rolled cancellation.
  • The principle: subtasks live inside a scope; leaving the scope = all subtasks finished. It's structured programming applied to threads.
  • The API: open() → fork() → join() → get(); try-with-resources guarantees cleanup; one subtask's failure cancels the rest.
  • The Joiner sets the policy: all succeed / first success / await all; configuration adds a group deadline.
  • ScopedValue is inherited by subtasks — context flows by itself. Status: preview (JEP 505); the API differed in 21–24.