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());
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);
}
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();
}
If I were reviewing this code, that is the sequence I would look for first:
-
forkthe related work inside one scope -
joinbefore reading results -
throwIfFailedbeforeget
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:
Top comments (0)