Structured Concurrency in Java: Fixing Unstructured Concurrent Code

Java’s concurrency model has been functional for decades. ExecutorService, Future, CompletableFuture — these tools work. They also have a persistent problem: when you submit tasks to an executor, the relationship between the spawning thread and the spawned tasks is not represented in the code. Errors are easy to lose. Cancellation doesn’t propagate automatically. Resources leak when tasks outlive their intended scope.

Structured concurrency, finalized in Java 24 (JEP 505 after multiple preview rounds), fixes this. The core principle: concurrent tasks should have lifetimes that are scoped to the block of code that spawned them, just as try-with-resources scopes resource lifetimes.

The Problem With Unstructured Concurrency

Consider a service that needs to fetch a user and their orders concurrently:

// Unstructured — what happens on errors or cancellation?
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Future<User> userFuture = executor.submit(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));

try {
    User user = userFuture.get(5, SECONDS);
    List<Order> orders = ordersFuture.get(5, SECONDS);
    return new UserProfile(user, orders);
} catch (ExecutionException e) {
    // Which task failed? What about the other task?
    // It's still running. We're leaking it.
    throw new RuntimeException(e.getCause());
} catch (TimeoutException e) {
    // We timed out waiting — but both tasks are still running.
    // We have no clean way to cancel them.
    throw new ServiceException("Timed out");
}

Problems:

  1. If fetchUser fails, fetchOrders keeps running until it finishes or we explicitly cancel it
  2. If fetchOrders fails after fetchUser succeeds, we’ve done wasted work
  3. If the calling thread is interrupted (request cancelled), both tasks keep running
  4. Error handling is verbose and error-prone

StructuredTaskScope: The Solution

StructuredTaskScope from java.util.concurrent provides structured concurrent execution:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> userTask       = scope.fork(() -> fetchUser(userId));
    Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));

    scope.join();           // Wait for both tasks to complete
    scope.throwIfFailed();  // Rethrow any exception from a failed task

    return new UserProfile(userTask.get(), ordersTask.get());
}
// When the try block exits:
// - All forked tasks are guaranteed to be complete
// - Any running tasks are cancelled
// - Resources are released

The try-with-resources block is the scope boundary. When execution leaves the block, all tasks forked within it are either completed or cancelled. No leaks.

The Built-In Policies

Two standard StructuredTaskScope implementations:

ShutdownOnFailure

If any forked task fails with an exception, the scope is shut down — remaining tasks are cancelled.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>       user    = scope.fork(() -> fetchUser(userId));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
    Subtask<Preferences> prefs  = scope.fork(() -> fetchPreferences(userId));

    scope.join().throwIfFailed(); // If any fails, others are cancelled

    return new Dashboard(user.get(), orders.get(), prefs.get());
}

If fetchOrders throws an exception, the scope shuts down, fetchPreferences is cancelled, and throwIfFailed() rethrows the exception from fetchOrders.

ShutdownOnSuccess

Returns when any task succeeds. Remaining tasks are cancelled. Useful for “try multiple sources, return the first result”:

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> fetchFromPrimaryCache(key));
    scope.fork(() -> fetchFromSecondaryCache(key));
    scope.fork(() -> fetchFromDatabase(key));

    scope.join(); // Returns when any task succeeds

    return scope.result(); // Returns the first successful result
}

Custom Policies

Beyond the built-ins, you can implement custom policies for specific coordination needs:

class CollectResultsScope<T> extends StructuredTaskScope<T> {
    private final List<T> results = new CopyOnWriteArrayList<>();
    private final List<Throwable> failures = new CopyOnWriteArrayList<>();

    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        switch (subtask.state()) {
            case SUCCESS -> results.add(subtask.get());
            case FAILED  -> failures.add(subtask.exception());
            default      -> {} // cancelled
        }
    }

    List<T> results() { return Collections.unmodifiableList(results); }
    List<Throwable> failures() { return Collections.unmodifiableList(failures); }
}

// Usage: collect all results, tolerate some failures
try (var scope = new CollectResultsScope<SearchResult>()) {
    searchSources.forEach(source -> scope.fork(() -> source.search(query)));
    scope.join();
    // Proceed even if some sources failed
    return merge(scope.results());
}

Error Propagation and Cancellation

Structured concurrency’s cancellation model is clean:

// Parent task cancellation propagates to children
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> task1 = scope.fork(() -> {
        // If interrupted, throws InterruptedException
        Thread.sleep(Duration.ofSeconds(10));
        return "done";
    });

    scope.join();
    // When the calling thread is interrupted while waiting in join(),
    // the scope shuts down, task1 receives interrupt, try-block exits cleanly
}

Compare this to CompletableFuture chains where cancellation requires explicit handling at every level.

Nesting Structured Task Scopes

Scopes can be nested, creating a tree of concurrent work with clear lifetime relationships:

try (var outerScope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<UserProfile> profileTask = outerScope.fork(() -> {
        // Inner scope — its tasks are scoped to this lambda
        try (var innerScope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<User> user   = innerScope.fork(() -> fetchUser(userId));
            Subtask<Settings> s  = innerScope.fork(() -> fetchSettings(userId));
            innerScope.join().throwIfFailed();
            return new UserProfile(user.get(), s.get());
        }
    });

    Subtask<List<Order>> ordersTask = outerScope.fork(() -> fetchOrders(userId));

    outerScope.join().throwIfFailed();
    return new Dashboard(profileTask.get(), ordersTask.get());
}

The lifetime hierarchy mirrors the code structure. user and settings are scoped to the inner task. profile and orders are scoped to the outer scope.

Observability With Structured Concurrency

Thread dumps with structured concurrency show task hierarchies rather than a flat list of threads. Tools that understand structured concurrency can display the parent-child relationships between tasks, making debugging concurrent programs significantly easier.

main
└─ StructuredTaskScope
   ├─ thread-1: fetchUser (running)
   ├─ thread-2: fetchOrders (blocked on I/O)
   └─ thread-3: fetchPreferences (completed)

This is one of the underappreciated benefits: the concurrency structure is visible in the code and in the runtime, not hidden in executor internals.

When to Use It

Structured concurrency is appropriate when:

  • You have multiple concurrent tasks that need to coordinate
  • Task lifetime should be bounded by a code scope
  • Error handling across tasks should be clean and predictable

It’s overkill when:

  • Tasks are genuinely fire-and-forget with no coordination needed
  • You have long-running background services (these aren’t “scoped” tasks)
  • You need dynamic task pools that grow and shrink over time

For most request-scoped concurrent work in web applications — fetching from multiple services, parallel data enrichment, concurrent I/O operations — structured concurrency is the right tool.

Status in Java

Structured concurrency was finalized in Java 24 (JEP 505). If you’re on Java 21 (current LTS as of 2024), it’s available as a preview feature (--enable-preview). Java 25 (LTS, September 2025) includes it as a stable API.

For new projects targeting Java 24+, structured concurrency is worth using over raw ExecutorService for scoped concurrent work. The cleaner error handling, automatic cancellation, and structural clarity are meaningful improvements.