Before you start

Structured concurrency is still a preview API in JDK 27 (JEP 533, seventh preview). Do not treat it as production-stable.

You must opt in at compile time and run time:

javac --release 27 --enable-preview Main.java
java --enable-preview Main

This post teaches the Java 25+ API shape: open a scope with StructuredTaskScope.open() and choose behavior with Joiner policies. Older examples that call public StructuredTaskScope constructors describe a superseded preview and should not guide new code.

Because preview APIs can change or disappear between JDK releases, use this post to learn and experiment. Keep production code on finalized concurrency APIs until structured concurrency becomes permanent.

Suppose one request needs a customer and that customer’s recent orders. The two calls are independent, so you submit both:

Response handle(String customerId)
        throws ExecutionException, InterruptedException {
    Future<Customer> customer =
            executor.submit(() -> customerService.find(customerId));
    Future<List<Order>> orders =
            executor.submit(() -> orderService.recent(customerId));

    return new Response(customer.get(), orders.get());
}

The code looks like one operation, but the executor sees separate tasks. If customer.get() fails, the orders task can keep running. If the request thread is interrupted, neither future is automatically cancelled. If the orders task fails first, the method may not notice while it waits for the customer.

You can add try / catch / finally, cancel each future manually, and shut down the executor correctly. That bookkeeping is exactly the problem.

Structured concurrency makes the task tree match the block structure of the code. Child tasks belong to one scope, and that scope cannot finish closing until its children have finished.

The Java 25+ factory shape

JDK 25 replaced the old public constructors with static open(...) factories. JDK 27 keeps that shape and refines its types and failure handling.

The basic workflow is:

  1. Open a scope in try-with-resources.
  2. Fork related subtasks.
  3. Join the scope once.
  4. Read or return the outcome.
  5. Let close() enforce the lifetime boundary.
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

Response handle(String customerId)
        throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<Customer> customer =
                scope.fork(() -> customerService.find(customerId));
        Subtask<List<Order>> orders =
                scope.fork(() -> orderService.recent(customerId));

        scope.join();

        return new Response(customer.get(), orders.get());
    }
}

The no-argument open() uses the default policy: all subtasks must succeed, and one failure cancels unfinished siblings. join() returns null; after it succeeds, each Subtask.get() exposes its result.

Note: Subtask.get() is not Future.get(). Do not use it as a per-task blocking join. Call scope.join() first, then inspect completed subtasks.

Fan-out, then fan-in

The pattern above is fan-out / fan-in:

  • Fan-out: fork independent calls so they run concurrently.
  • Fan-in: join once, then compose their results into the parent result.

The scope gives the group one lifetime. When control leaves the try block, no child thread from that scope remains alive.

This is stronger than “remember to call get() on every future.” The lexical boundary is enforced even when a subtask fails, the owner is interrupted, or response construction throws.

Fail one, cancel siblings

The default policy is designed for all-or-nothing work. If one child fails, the scope cancels unfinished siblings by interrupting their threads.

Response handle(String customerId)
        throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        var customer = scope.fork(() -> customerService.find(customerId));
        var orders = scope.fork(() -> orderService.recent(customerId));
        var offers = scope.fork(() -> offerService.forCustomer(customerId));

        scope.join(); // one failure cancels unfinished siblings

        return new Response(customer.get(), orders.get(), offers.get());
    }
}

In JDK 27, a failed child makes the default join() throw ExecutionException. Inspect getCause() to log, translate, or propagate the original failure after the scope closes.

Cancellation is cooperative. A cancelled subtask must respond to interruption. Code stuck in non-interruptible I/O or an infinite loop can delay close() indefinitely because the scope waits for every child to terminate.

Do not swallow InterruptedException and continue as if nothing happened. Restore the interrupt or propagate it according to the method contract.

Pick a Joiner policy

StructuredTaskScope.Joiner defines when the scope has enough information to finish and what join() returns. Create a fresh joiner for each scope; joiners are not reusable shared configuration objects.

Default: heterogeneous all-or-nothing

Use StructuredTaskScope.open() when child result types differ and the parent needs each named Subtask:

try (var scope = StructuredTaskScope.open()) {
    var profile = scope.fork(() -> loadProfile(userId));
    var permissions = scope.fork(() -> loadPermissions(userId));

    scope.join();
    return new Session(profile.get(), permissions.get());
}

One child failure short-circuits the operation, cancels unfinished siblings, and makes join() throw ExecutionException.

allSuccessfulOrThrow(): collect homogeneous results

When every child returns the same type, this policy returns a List<T> directly:

List<Price> loadPrices(List<String> symbols)
        throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open(
            Joiner.<Price>allSuccessfulOrThrow())) {
        symbols.forEach(symbol ->
                scope.fork(() -> priceService.fetch(symbol)));

        return scope.join();
    }
}

It short-circuits on failure and cancels unfinished tasks. It has the same success requirement as the default policy, but a different result shape.

anySuccessfulOrThrow(): first success wins

Use this for redundant providers or hedged reads where any valid answer is enough:

Quote fastestQuote(String symbol)
        throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open(
            Joiner.<Quote>anySuccessfulOrThrow())) {
        scope.fork(() -> primary.quote(symbol));
        scope.fork(() -> replica.quote(symbol));
        scope.fork(() -> backup.quote(symbol));

        return scope.join();
    }
}

The first successful result wins and unfinished siblings are cancelled. If every subtask fails, join() throws ExecutionException.

Other built-ins include awaitAllSuccessfulOrThrow(), which waits for all subtasks, and allUntil(predicate), which stops when all finish or the predicate says enough information has arrived. Overloads of the ...OrThrow factories can map failure to a custom exception type.

Prefer these policies until you truly need a custom Joiner; custom completion callbacks may run concurrently, so their state must be thread-safe.

Add a timeout at the scope boundary

The factory shape also centralizes configuration. In JDK 27, pass a configuration operator to set a timeout, thread factory, or scope name.

import java.time.Duration;

List<Price> loadPrices(List<String> symbols)
        throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open(
            Joiner.<Price>allSuccessfulOrThrow(),
            config -> config.withTimeout(Duration.ofSeconds(2)))) {
        symbols.forEach(symbol ->
                scope.fork(() -> priceService.fetch(symbol)));

        return scope.join();
    }
}

If the deadline expires, the scope cancels incomplete children. With this JDK 27 joiner, join() throws ExecutionException whose cause is CancelledByTimeoutException.

The important design choice is not the exact duration. It is that the deadline applies to the whole operation, not independently to futures that may each consume the complete request budget.

Pair it with virtual threads

Structured concurrency is the control structure; virtual threads are the cheap execution mechanism. By default, each fork() starts a virtual thread.

Virtual threads make “one thread per blocking I/O task” affordable. Structured concurrency makes a family of those threads safe to coordinate:

  • The parent owns the children.
  • Failure can cancel siblings.
  • Parent cancellation flows down the tree.
  • A thread dump can display the task hierarchy.

Virtual threads solve scalability; structured concurrency solves lifetime and coordination.

Neither adds CPU cores. For pure CPU-bound loops, use bounded parallelism appropriate to the hardware. For blocking HTTP, JDBC, and file I/O, virtual child threads are the natural fit.

Also keep downstream limits. Forking 10,000 virtual threads does not create 10,000 database connections.

Pair it with scoped values

Child tasks inherit ScopedValue bindings from the owner. That makes scoped values a clean way to carry immutable request context through a task tree.

private static final ScopedValue<String> REQUEST_ID =
        ScopedValue.newInstance();

Response serve(Request request) throws Exception {
    return ScopedValue.where(REQUEST_ID, request.id()).call(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var customer = scope.fork(() -> {
                audit("load-customer", REQUEST_ID.get());
                return customerService.find(request.customerId());
            });
            var orders = scope.fork(() -> {
                audit("load-orders", REQUEST_ID.get());
                return orderService.recent(request.customerId());
            });

            scope.join();
            return new Response(customer.get(), orders.get());
        }
    });
}

Both children see the same immutable binding, and the binding disappears when call() returns. Read Scoped Values for binding rules, get() behavior, and why Java 25 finalized them as a safer alternative to app-owned request ThreadLocals.

Use ordinary parameters for domain inputs such as customerId. Use scoped values for cross-cutting context such as request IDs, principals, or tracing metadata.

What JDK 27 changes

Structured concurrency incubated in JDK 19–20 and entered preview in JDK 21. JDK 25 replaced public constructors with open(...) factories and introduced Joiner; JDK 27’s seventh preview gives scopes and joiners a typed join-exception parameter, and its standard failure policies use ExecutionException.

JEP 533 also removes Joiner.awaitAll(), replaces the custom-joiner onTimeout() hook with timeout(), and adds an open(configurationOperator) overload for the default policy.

The churn is evidence for the opening warning: preview means the source and binary contract is not stable yet.

Gotchas

  • Owner rules: The opening thread owns the scope. Do not stash it in a field, pass it to unrelated code, or treat it as an application-wide executor.
  • Close waits: Cancellation interrupts children, but close() waits for termination. Non-interruptible work can defeat an expected deadline.
  • Join first: Fork, call join() once, then read Subtask results.
  • Fresh joiner: Create a joiner per scope; never reuse one.
  • Preview everywhere: Compiler, tests, IDE, build, and launcher all need compatible preview settings.

Cheat sheet

JDK 27: JEP 533, seventh preview — not production-stable
Compile: javac --release 27 --enable-preview Main.java
Run:     java --enable-preview Main

Default heterogeneous fan-in:
  StructuredTaskScope.open()
  fork(...) -> Subtask<T>
  join()
  subtask.get()

Homogeneous all-or-nothing:
  StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())

First success wins:
  StructuredTaskScope.open(Joiner.anySuccessfulOrThrow())

Java 25+ shape: static open(...) factories, not public constructors
Default fork thread: virtual thread
Failure: ExecutionException in JDK 27 standard policies
Cancellation: interruption; children must cooperate
Context: ScopedValue bindings are inherited by child tasks

Do:

  • Put every scope in try-with-resources.
  • Fork only tasks that belong to the same parent operation.
  • Choose a Joiner that states the success rule.
  • Propagate interruption and bound downstream resources.
  • Apply one deadline to the complete fan-out operation.

Don’t:

  • Copy old examples that call public StructuredTaskScope constructors.
  • Reuse one joiner across multiple scopes.
  • Read child results before join().
  • Swallow interrupts in cancellable subtasks.
  • Ship a seventh-preview API as if Java guaranteed compatibility.

Wrap-up

Structured concurrency turns a loose collection of concurrent tasks into one bounded operation. Open a scope, fork children, join once, and let the scope enforce that no child outlives the parent block.

The Java 25+ API centers on StructuredTaskScope.open(...) and Joiner policies. In JDK 27, the default and standard fail-fast policies report child failure through ExecutionException, while sibling and parent cancellation flow through interruption.

Experiment with a small blocking I/O fan-out on JDK 27 using --enable-preview. Pair it with virtual threads for cheap child execution and scoped values for inherited immutable context — but wait for a finalized API before relying on structured concurrency in production.

Next optional step in the series Share immutable request context without ThreadLocal tax. Scoped Values: Share Immutable Context Without ThreadLocal Pain