StructuredTaskScope is the API that makes structured concurrency concrete in Java. You open a scope, fork subtasks into it, wait for them with join(), and close it, and the language's try-with-resources block guarantees that no subtask thread outlives the block that started it. If one subtask fails, the others are cancelled. If the caller is interrupted, the whole tree is cancelled. A thread dump shows the subtasks as children of their owner rather than as anonymous pool threads.

The catch is that the API is still a preview, and it has changed shape several times. Code written against JDK 21 does not compile on JDK 25, and JDK 27 changed the exception that join() throws. This article covers the current API as delivered in JDK 27 by JEP 533, explains what changed and why, and walks through a realistic service-aggregation example, custom joiners, timeouts, failure modes and a migration plan. For the design argument behind structured concurrency, read Java structured concurrency architecture first.

Advertisement

The problem it solves

Consider a handler that needs a user record and an order history from two services. With an ExecutorService you submit both calls and call get() on each future. If the user call fails quickly, you throw, but the order call keeps running in the pool, holding a connection and doing work nobody will read. If the handler thread is interrupted while waiting, neither future is cancelled. Nothing in the code ties the lifetime of the two tasks to the method that created them.

Structured concurrency adds one rule: a task that forks subtasks must wait for them before it returns, and the subtasks are confined to a lexical block. That rule gives you three properties for free. Errors propagate upward, because the parent sees every child's outcome. Cancellation propagates downward, because the parent can interrupt every child it owns. And observability follows the code structure, because the runtime knows the parent of every thread. CompletableFuture can express the same data flow, but it cannot enforce any of the three.

Version map: which API you actually have

Before writing code, check your JDK. The API has gone through two incubator rounds and seven previews. The big break came in JDK 25, which replaced the subclass-per-policy design with a single interface plus pluggable joiners.

JDKJEPShape
19, 20428, 437Incubator in jdk.incubator.concurrent
21 to 24453, 462, 480, 499Preview; new StructuredTaskScope.ShutdownOnFailure(), join() then throwIfFailed()
25505StructuredTaskScope.open(...) static factories and the Joiner interface
26525allSuccessfulOrThrow() returns List<T>; anySuccessfulResultOrThrow() renamed anySuccessfulOrThrow(); configuration takes a UnaryOperator; new Joiner.onTimeout()
27533Third type parameter for the exception join() throws; standard joiners throw ExecutionException instead of FailedException; awaitAll() removed; onTimeout() renamed timeout()

Every preview requires --enable-preview at both compile and run time, and preview APIs are tied to one release: a class compiled with preview features for JDK 26 will not run on JDK 27. Pin the JDK version in your build and treat each upgrade as a small migration. Everything below targets JDK 27.

Advertisement

Lifecycle: open, fork, join, close

A scope is a sealed interface, StructuredTaskScope<T, R, R_X extends Throwable>. T is the result type of the subtasks, R is what join() returns, and R_X is the exception type join() throws when the scope fails. You never implement it; you get one from a static open method. The thread that calls open becomes the owner. Only the owner may call join() and close().

fork(Callable) starts a subtask in a new thread and returns a Subtask<T> handle immediately. By default each subtask runs in its own virtual thread, so forking thousands of blocking calls is cheap; see virtual threads for why. join() blocks the owner until the joiner decides the scope is done, then returns the joiner's result or throws. close() cancels the scope if it is still open and waits until every forked thread has terminated. Because close() runs at the end of the try block on every path, including exceptions, no subtask can leak.

One scope, one owner: subtasks cannot outlive the block that forked themopen(joiner, cfg)owner thread creates scopefork() x None virtual thread eachjoin()owner waits on joinerclose()waits for every threadSubtask ASUCCESSSubtask BFAILEDSubtask CUNAVAILABLEJoineronFork / onCompletereturns true = cancel scopecompletionCancellationinterrupt unfinished subtasksjoin() outcomeresult R or throws R_XresultDefault policy: any FAILED subtask cancels the rest and join() throws; timeout cancels everything still running.
The owner forks subtasks, the joiner observes each completion and may cancel the scope, and join() turns the joiner's verdict into a result or an exception.

A Subtask has a state() of SUCCESS, FAILED or UNAVAILABLE. The last one covers subtasks that have not finished, were cancelled, or were forked after cancellation. After join() returns, get() returns the result of a successful subtask without blocking, and exception() returns the exception of a failed one. Calling either on the wrong state throws IllegalStateException, which is the API telling you that you read a handle before joining or ignored its state.

Worked example: aggregating three services

A product page needs pricing, inventory and reviews. Pricing and inventory are mandatory; reviews are optional. The simplest version uses the default policy from the no-argument open(): if any subtask fails, the scope is cancelled and join() throws an ExecutionException whose cause is the first failure. If all succeed, join() returns null and you read each handle.

import java.time.Duration;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

record ProductPage(Price price, Stock stock, Reviews reviews) {}

ProductPage load(String sku) throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open(
            cfg -> cfg.withTimeout(Duration.ofMillis(800)).withName("product-" + sku))) {
        Subtask<Price>   price   = scope.fork(() -> pricing.quote(sku));
        Subtask<Stock>   stock   = scope.fork(() -> inventory.level(sku));
        Subtask<Reviews> reviews = scope.fork(() -> safeReviews(sku));
        scope.join();   // throws ExecutionException if any subtask failed or time ran out
        return new ProductPage(price.get(), stock.get(), reviews.get());
    }
}

// Optional dependency: degrade inside the subtask, never fail the scope.
Reviews safeReviews(String sku) {
    try {
        return reviewService.top(sku, 5);
    } catch (Exception e) {
        log.warn("reviews unavailable for {}", sku, e);
        return Reviews.EMPTY;
    }
}

Three details matter. The timeout lives in the configuration, so a slow inventory call cannot hold the request thread beyond 800 ms. The optional dependency handles its own failure, because the default policy treats every failure as fatal and the right place to decide what is optional is inside the subtask. And the method declares InterruptedException: if the request thread is interrupted, join() throws it, and leaving the try block cancels and waits for all three subtasks.

The built-in joiners

A joiner is the policy object that decides when the scope is finished and what join() produces. Pass one to open(joiner) or open(joiner, cfg). JDK 27 ships these factories on StructuredTaskScope.Joiner:

Factoryjoin() returnsCancels the scope when
allSuccessfulOrThrow()List<T> of results in fork orderany subtask fails
anySuccessfulOrThrow()the first successful Tany subtask succeeds; throws only if all fail
awaitAllSuccessfulOrThrow()null; read handles yourselfany subtask fails
allUntil(Predicate)List<Subtask<T>>the predicate returns true for a completed subtask

The three ...OrThrow factories also have overloads that take a function producing the exception to throw, so a service can surface its own domain exception instead of ExecutionException. That is why the scope carries R_X: the compiler knows which checked exception join() can throw. JDK 26's awaitAll(), which waited for everything regardless of outcome, is gone; allUntil(s -> false) gives the same behaviour and hands back every subtask so you can inspect each state.

anySuccessfulOrThrow() is the tool for hedged requests. Fork the same read against two replicas, take whichever answers first, and the scope interrupts the slower one:

String readHedged(String key) throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open(
            StructuredTaskScope.Joiner.<String>anySuccessfulOrThrow())) {
        scope.fork(() -> replicaA.get(key));
        scope.fork(() -> { Thread.sleep(20); return replicaB.get(key); }); // hedge after 20 ms
        return scope.join();
    }
}

Writing a custom joiner

When none of the built-ins fit, implement Joiner<T, R, R_X>. The runtime calls onFork when a subtask is forked and onComplete when one finishes; returning true from either cancels the scope. result() is called by join() after the scope is done and produces the return value or throws. timeout() is called instead when the configured timeout cancelled the scope; it may return a partial result or throw, and if it throws, the runtime attaches a CancelledByTimeoutException as the cause.

onComplete runs on the subtask's thread, concurrently with other completions, so joiner state must be thread-safe. Here is a quorum joiner for a replicated read: succeed once two of three replicas agree, fail once agreement is impossible, and return what is known on timeout.

final class QuorumJoiner<T> implements StructuredTaskScope.Joiner<T, T, ExecutionException> {
    private final int needed, total;
    private final ConcurrentHashMap<T, AtomicInteger> votes = new ConcurrentHashMap<>();
    private final AtomicInteger finished = new AtomicInteger();
    private volatile T winner;

    QuorumJoiner(int needed, int total) { this.needed = needed; this.total = total; }

    @Override public boolean onComplete(Subtask<T> s) {
        int done = finished.incrementAndGet();
        if (s.state() == Subtask.State.SUCCESS) {
            int n = votes.computeIfAbsent(s.get(), k -> new AtomicInteger()).incrementAndGet();
            if (n >= needed) { winner = s.get(); return true; }      // quorum: cancel stragglers
        }
        int best = votes.values().stream().mapToInt(AtomicInteger::get).max().orElse(0);
        return best + (total - done) < needed;                        // quorum impossible: stop
    }

    @Override public T result() throws ExecutionException {
        if (winner != null) return winner;
        throw new ExecutionException(new IllegalStateException("no quorum"));
    }

    @Override public T timeout() throws ExecutionException {
        throw new ExecutionException(new IllegalStateException("quorum timed out"));
    }
}

Keep joiners small and side-effect free. They decide; they should not call services, log heavily or block, because they run on the critical path of every completion.

Cancellation and timeouts are interrupts

Cancelling a scope interrupts the threads of unfinished subtasks. It does not kill them. A subtask stops only if it is blocked in an interruptible call, such as socket I/O on a virtual thread, Thread.sleep or a BlockingQueue operation, or if it checks Thread.interrupted() in its own loops. A subtask that spins over a large in-memory computation without checking will run to the end, and close() will wait for it. Cancellation is cooperative, and the timeout is only as good as the subtasks' willingness to stop.

Two rules follow. Do not swallow InterruptedException inside a subtask; let it propagate or restore the flag. And check interruption in long CPU loops. With the default joiner, a timeout causes join() to throw an ExecutionException whose cause is a CancelledByTimeoutException, so callers that want to map timeouts to HTTP 504 should test the cause rather than the outer type.

Scoped values, nesting and structure rules

Subtasks inherit the owner's ScopedValue bindings. If the request handler binds a tenant id or trace context with ScopedValue.where(...).run(...), every subtask reads the same value without copying it into each lambda. This is the replacement for passing ThreadLocal state into pool threads, and it is cheaper because nothing is copied.

Scopes nest: a subtask may open its own scope, producing a tree. The runtime enforces that the tree stays a tree. If a scope is closed while a scope opened inside it is still open, or if a scoped-value binding opened before the scope is exited while the scope is still open, the API throws StructureViolationException. In practice you only see it when a scope escapes its try block, for example when it is stored in a field. Do not do that.

Observability: thread dumps that show the tree

Virtual threads do not appear in classic jstack output in a useful way, so use the JSON thread dump. Each scope appears as an object listing the threads forked in it, with stack traces and a reference to its parent scope, so you can reconstruct the call tree. Naming scopes with withName makes the dump readable.

jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
# then find the scope named product-SKU123 and the subtasks still blocked in it

During an incident, this answers the question that flat pool dumps cannot: which request owns the 400 threads blocked on the inventory service.

Migrating from the JDK 21 to 24 shape

Older code looks like try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { ...; scope.join().throwIfFailed(); }. The mapping is mechanical. ShutdownOnFailure becomes the default open(); ShutdownOnSuccess becomes open(Joiner.anySuccessfulOrThrow()) with the result returned by join(); joinUntil(deadline) becomes a withTimeout configuration; subclasses that overrode handleComplete become custom joiners. Catch blocks that expected FailedException from JDK 25 or 26 must now catch ExecutionException and unwrap the cause.

Wrap the scope in a thin helper of your own, such as Parallel.all(...), so the next preview change touches one file rather than every call site.

Trade-offs and failure modes

  • Preview churn. Each release can rename methods; budget a short migration per JDK upgrade or stay on an LTS release with its preview shape.
  • Uninterruptible work. Legacy blocking libraries that ignore interrupts make cancellation and timeouts ineffective. Test cancellation explicitly.
  • Unbounded fan-out. Virtual threads make forking cheap, but downstream services are not. Bound concurrency with a semaphore inside the subtask.
  • Fatal optional calls. Under the default policy one optional dependency failing fails the request. Degrade inside the subtask.
  • Pinned carriers. Long native calls inside subtasks can still hold carrier threads; profile before forking thousands of them. Compared with an ExecutorService, you trade explicit pool tuning for lifetime guarantees.

What to do next

  1. Check which JDK you build with and read the JEP for that release; do not copy examples from another release.
  2. Pick one handler that fans out to two or three services and rewrite it with open(), a timeout and a named scope.
  3. Move every optional dependency's fallback inside its own subtask.
  4. Write a test that fails one subtask and asserts the others were interrupted and the handler returned within the timeout.
  5. Audit subtasks for swallowed InterruptedException and CPU loops without interrupt checks.
  6. Take a JSON thread dump under load and confirm the scope tree is readable.
  7. Hide the API behind a small internal helper so the next preview change is a one-file edit.
Key takeaway: StructuredTaskScope confines subtasks to a try-with-resources block: open a scope, fork subtasks into virtual threads, join through a joiner that decides success and cancellation, and close, which waits for every thread. In JDK 27 the scope carries the exception type join() throws, the standard joiners throw ExecutionException, and timeouts surface as a CancelledByTimeoutException cause. Cancellation is an interrupt, so subtasks must cooperate. Because the API is still a preview, pin your JDK and isolate it behind a helper.