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 (2)

Collapse
 
khmarbaise profile image
Karl Heinz Marbaise

Why using JDK 21 for demonstrating structured concurrency instead of using the most recent version JDK 26 .. ?

Collapse
 
jagdish_salgotra_e253b2d3 profile image
Jagdish Salgotra

I picked JDK 21 on purpose, even though newer releases exist.

A big reason is that structured concurrency in Java 21 shows the original preview model. For teaching, I think that matters. It is easier to understand the core idea first, what problem it is solving, why the scope owns the lifecycle, why join() and throwIfFailed() matter, before jumping into the later API redesigns.

The second reason is just practical. JDK 25 is the current LTS, and JDK 26 is the latest feature release, but a lot of real systems are still on JDK 21, or are planning a migration path from 21 to 25 rather than moving straight to a non-LTS release. So starting with 21 felt closer to what many teams are actually using or migrating from.

The third reason is that the API really did move:

  • JDK 21: original preview model
  • JDK 25: major redesign with StructuredTaskScope.open(...) and Joiner
  • JDK 26: sixth preview, mostly refinement on top of that newer shape

So I did not want to mix two different jobs into one article:

  • teaching the mental model
  • teaching the latest API migration story

That is why the series starts with Java 21 as the baseline, and then has a separate migration part for Java 21 -> Java 25/26.

If you are already on JDK 25 or 26, that is completely fair. In that case, I would treat Part 1 as the conceptual foundation and Part 9 as the API update path.

Thank you for reading and sharing your valuable feedback.