DEV Community

Jagdish Salgotra
Jagdish Salgotra

Posted on

Structured Concurrency in Java 21: What It Fixes and Why It Exists

Note
This series uses the Java 21 preview API for structured concurrency (StructuredTaskScope, JEP 453). The API evolved in later previews. See Part 9 for Java 21 -> Java 25 migration guidance. Compile and run with --enable-preview.

Originally published on engnotes.dev:

Introduction to Structured Concurrency in Java 21

This is a shortened version with the same core code and takeaways.

The annoying part of concurrent request code is usually not starting work in parallel. Java has had plenty of ways to do that for years.

The real mess shows up later. One task fails. Another keeps running. Cancellation is inconsistent. Cleanup is half in one method and half somewhere else. That is the problem structured concurrency is trying to fix.

The Old Shape

This is a very normal baseline from the project:

CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> simulateService("Service-A", 200));
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> simulateService("Service-B", 300));
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> simulateService("Service-C", 100));

CompletableFuture.allOf(cf1, cf2, cf3).join();

String result = String.format("Results: %s, %s, %s",
    cf1.get(), cf2.get(), cf3.get());
Enter fullscreen mode Exit fullscreen mode

This works. The problem is that failure policy and lifecycle are not obvious from the structure of the code. Once timeouts, cancellation, and cleanup matter, that usually spreads out across multiple call sites.

The Java 21 Preview Model

Structured concurrency gives the related work one explicit scope owner:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var task1 = scope.fork(() -> simulateService("Service-A", 200));
    var task2 = scope.fork(() -> simulateService("Service-B", 300));
    var task3 = scope.fork(() -> simulateService("Service-C", 100));

    scope.join();
    scope.throwIfFailed();

    String result = String.format("Results: %s, %s, %s",
        task1.get(), task2.get(), task3.get());
    logger.info(result);
}
Enter fullscreen mode Exit fullscreen mode

That is the real shift.

The tasks live in one scope. The failure policy is visible. Cleanup happens with scope exit. It reads much closer to the actual lifecycle of the request.

The Java 21 Review Rule

In the Java 21 preview API, the safe baseline is simple:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var future = scope.fork(task);
    scope.join();
    scope.throwIfFailed();
    return future.get();
}
Enter fullscreen mode Exit fullscreen mode

If I were reviewing this code, that is the sequence I would look for first:

  • fork the related work inside one scope
  • join before reading results
  • throwIfFailed before get

Miss that middle step, or skip throwIfFailed, and the code gets harder to trust.

Where It Fits Well

Structured concurrency is a good fit for:

  • I/O-heavy fan-out request paths
  • service aggregation with one clear failure policy
  • code that needs reliable cancellation and cleanup

It is less interesting for:

  • pure CPU-heavy batch work
  • long-lived background processing
  • teams that cannot use preview APIs in production yet

It also pairs naturally with virtual threads. Virtual threads keep blocking code direct. Structured scopes keep the lifecycle visible.

The Practical Takeaway

What I like about structured concurrency is that it makes concurrent work look like one operation again instead of a loose collection of tasks.

That sounds small, but it changes how readable the code is and how predictable failure handling becomes.

Full article with more examples, Java 21 preview setup details, runnable repo, and live NoteSensei chat:

Introduction to Structured Concurrency in Java 21

Top comments (0)