Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Streaming results as subtasks finish suits user-facing flows; nested scopes mirror the service tree. What each costs.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
All code in this series targets Java 21 preview APIs. Part 9 covers migration to Java 25.
A caller may not want to wait silently while five independent sections load. A CLI may want to print completed steps. A UI may want to stream finished sections as they arrive. A batch job may need to record which child phase finished before a later phase failed.
Structured concurrency can support that, but progress has to stay inside the ownership boundary. The mistake is to treat progress as an excuse to detach work from its parent. Once a task is detached, the parent no longer knows whether it is still running, failed, cancelled, or leaking capacity.
The central tension is that a progress API usually needs at least two concepts: terminal progress, where the task is no longer running, and successful output, where the task produced usable data. Treat those as the same thing and the code will eventually report "completed" for work that actually failed.
This article is still learning material. The examples are small, structured concurrency is still preview, and the measurements below are local checks from the companion repository. The goal is to make the workflow shape visible, not to claim production performance.
Progressive results do not mean "start work somewhere and hope updates arrive."
They mean the parent still owns the scope, while each child reports a terminal state as it finishes.
In Java 21 preview syntax, the core shape looks like this:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
List<StructuredTaskScope.Subtask<String>> subtasks = new ArrayList<>();
for (int i = 0; i < tasks.size(); i++) {
int taskIndex = i;
subtasks.add(scope.fork(() -> {
String result = tasks.get(taskIndex).call();
progress.accept(new ProgressUpdate<>(taskIndex, result, null));
return result;
}));
}
Instant deadline = Instant.now().plus(timeout);
boolean[] observed = new boolean[subtasks.size()];
int observedCount = 0;
while (observedCount < subtasks.size() && Instant.now().isBefore(deadline)) {
try {
scope.joinUntil(Instant.now().plusMillis(50));
} catch (TimeoutException ignored) {
// The short join is used only to avoid a hot polling loop.
}
for (int i = 0; i < subtasks.size(); i++) {
if (!observed[i] && subtasks.get(i).state() != Subtask.State.UNAVAILABLE) {
observed[i] = true;
observedCount++;
}
}
}
if (observedCount < subtasks.size()) {
scope.shutdown();
}
scope.join();
scope.throwIfFailed();
}
The important idea is not the exact polling interval. The important idea is that the parent remains the owner. The parent starts the tasks, observes terminal states, joins the scope, and closes the scope.
This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. The Article 4 measurements below were generated from the current Java 25 code. The Java 21 preview syntax shown here remains valid for learning purposes, and Part 9 covers the migration details. In that migrated implementation, HierarchicalProgressiveHandler.executeProgressive() publishes progress from inside each subtask and only joins the scope from the owner. That matters because the Java 25 preview API enforces a stricter rule: the owner must join after forking, and consuming subtask results before the owner has joined is not valid.
The measured Article 4 paths use timeouts that are longer than the simulated work. They exercise progress observation and nested ownership, not the hard-timeout branch from Part 2.
That rule is not just API ceremony. It protects the same idea this whole series is about: the parent owns the lifecycle of the work it started.
The first demo used five simulated services:
The run completed in this order:
Task 4 completed: NotificationService response (processed in 150ms)
Task 0 completed: UserService response (processed in 200ms)
Task 3 completed: ShippingService response (processed in 250ms)
Task 1 completed: InventoryService response (processed in 300ms)
Task 2 completed: PaymentService response (processed in 400ms)
Progressive execution completed: 5/5 tasks in 444ms
That is the behavior progressive results are supposed to expose. The parent did not have to wait until the 400ms payment task finished before learning that notification, user, shipping, and inventory work had already completed.
The total duration was 444ms, not exactly 400ms. That is expected in this implementation. The Java 25 demo polls every 50ms, logs heavily, and records progress callbacks. The teaching point is that the whole scope finished near the slowest child, while progress arrived in the order children actually completed.
That order also gives you a useful test target. The exact wall-clock numbers will move from run to run, but the fast 150ms task should be observed before the 400ms task unless the machine is under unusual load.
The next shape is hierarchy.
Hierarchy is not just "this method calls another method." That is ordinary call structure. Structured hierarchy means each layer that starts concurrent work also owns the scope for that work.
A Java 21 preview nested-scope example looks like this:
public String executeHierarchical() throws Exception {
try (var parentScope = new StructuredTaskScope.ShutdownOnFailure()) {
var group1 = parentScope.fork(() -> executeChildTasks("Group-1"));
var group2 = parentScope.fork(() -> executeChildTasks("Group-2"));
var group3 = parentScope.fork(() -> executeChildTasks("Group-3"));
parentScope.join();
parentScope.throwIfFailed();
return "Parent completed: [%s, %s, %s]"
.formatted(group1.get(), group2.get(), group3.get());
}
}
private String executeChildTasks(String group) throws Exception {
try (var childScope = new StructuredTaskScope.ShutdownOnFailure()) {
var first = childScope.fork(() -> callFirstChild(group));
var second = childScope.fork(() -> callSecondChild(group));
childScope.join();
childScope.throwIfFailed();
return "%s: [%s, %s]".formatted(group, first.get(), second.get());
}
}
There are two ownership boundaries here. The parent owns the group tasks. Each group owns its own child tasks. A failure in a child group can be handled at the group boundary or allowed to fail the parent, but the decision is local and visible.
The standalone Article 4 demo has a simple three-level hierarchical sequence:
Level 1 (Data Gathering) completed
Level 2 (Business Logic) completed
Level 3 (Finalization) completed
Hierarchical[DataGathered -> BusinessProcessed -> OrderFinalized]
Duration: 1742ms
That specific demo is intentionally sequential. Its simulated sleeps add up to about 1710ms before overhead, so the measured 1742ms is exactly what the code suggests. It is useful for teaching phase ownership, not for demonstrating parallel speedup.
The nested-scope benchmark code in ProgressiveResultsBenchmark is the better example of hierarchical fan-out. Its HierarchicalTaskManager starts a parent scope, then starts child scopes for user data and business logic. That is the shape to look for when reviewing real code: every layer that forks work must also join and close that work.
The combined demo runs named levels concurrently, and each level uses progressive tracking for its own children.
In one local run, the three levels reported:
DataPreparation: 209ms
BusinessProcessing: 260ms
OrderFinalization: 421ms
Total duration: 425ms
Completed levels: [DataPreparation, BusinessProcessing, OrderFinalization]
The important number is the total duration. The levels add up to 890ms if you sum them, but the parent finished in 425ms because the levels ran as siblings and the slowest level was 421ms.
That is a good place to be precise about language. The run also included two simulated temporary failures inside non-terminal child tasks:
DataPreparation: Task 0 failed
OrderFinalization: Task 1 failed
The parent still reported the three levels as completed because the current handler treats "every child reached a terminal state" as completion. That is not the same thing as "every child produced business data."
The repository code exposes both pieces through ProgressiveResult.getResults() and ProgressiveResult.getErrors(). An article or UI should not collapse those into one green checkmark unless the product contract really allows partial output.
The larger Article 4 demo uses four order-processing phases:
The parent result was:
Processing Time: 362ms
Completed Phases: [UserDataCollection, ProductInventoryProcessing, PaymentShippingProcessing, OrderFinalization]
Again, the parent duration tracks the slowest phase, not the sum of all phase durations.
That is useful for learning because it makes the ownership tree visible:
Order
UserDataCollection
profile
preferences
payment methods
addresses
ProductInventoryProcessing
availability
pricing
discounts
inventory reservation
PaymentShippingProcessing
payment
shipping
delivery scheduling
OrderFinalization
confirmation
email
inventory update
analytics
This is not a recommendation to parallelize every real order-processing phase. A real payment flow often has hard dependencies: you may not be allowed to reserve inventory, charge payment, and send confirmation independently. The demo is a controlled concurrency shape, not a business-process template.
The lesson is narrower and more useful: if a workflow has independent phases, structure the code so the parent owns the phases and each phase owns its children.
The dedicated benchmark class runs four synthetic approaches for 30 seconds each with 100 concurrent users:
The raw result from this run was:
The memory summary at the end of the run was:
Total Memory: 64 MB
Used Memory: 17 MB
Free Memory: 46 MB
Max Memory: 12288 MB
These numbers are useful, but only if you read the benchmark code.
The traditional path is intentionally sequential and includes sleeps between service calls. The structured paths run independent simulated work on virtual threads. The combined path also uses shorter request think times between iterations than the traditional path. So this table does not prove that "structured concurrency is 6x faster" in general.
What it does prove is smaller and more defensible:
The benchmark generated real progress events, and it generated a lot of them. The progressive path recorded 96,752 progress updates. The combined path recorded 272,930 progress updates. That gives the article a concrete grounding point: progress is not a decorative callback in this code; it is exercised heavily under concurrent load.
The final task counters also came from the benchmark run:
shipping tasks: 19495
profile tasks: 19495
payment tasks: 19495
inventory tasks: 19495
confirmation tasks: 0
Those counters are for the last scenario only because the benchmark resets metrics before each scenario. The confirmation counter is zero in the combined scenario because that code path does not call createOrderConfirmation(). That is another reminder to cite raw benchmark output carefully. A counter is only meaningful after you know which scenario produced it.
During this pass, the first Article 4 demo run failed before producing usable measurements.
The failure was not a flaky benchmark. It was an ownership bug in the migrated Java 25 code:
java.lang.IllegalStateException: join not called
java.lang.IllegalStateException: Owner did not join after forking
The handler was observing subtask states and then calling Subtask.get() before the owner had joined the scope. Java 25 rejected that.
The fix was small: each subtask stores or publishes its own result before returning, the parent observes terminal states without calling get(), and the owner calls scope.join() before the scope closes.
That fix is worth mentioning because it reinforces the central rule. Progressive reporting is fine. Reading child results outside the ownership protocol is not.
For Java 21 preview readers, the equivalent discipline is to keep joinUntil(...), join(), throwIfFailed(), and result access in a clear order. Progress can be emitted while children finish, but the parent still owns the final join.
Progressive and hierarchical flows need tests that check state transitions, not just final strings.
For progressive execution, check that each task produces at most one progress update, that the final result separates successful outputs from failures, and that a timeout or failure does not leave child work running outside the scope. The order of progress updates should usually be treated as data, not as a fixed assertion, unless the delays are controlled and deterministic.
For hierarchical execution, check the boundary where a child scope fails. A child failure may fail the parent, degrade one level, or return partial data, but the code should make that policy explicit. The parent should not accidentally call a partially successful level "fully successful" just because all children reached terminal state.
For benchmark evidence, prefer raw per-scenario tables over generated improvement summaries. The raw table tells you requests, latency, throughput, and progress-update count. Percentage improvement labels are easy to misread unless the benchmark code and scenario timings are included.
Part 5 moves from workflow shape to capacity shape. Once a parent can fork many children and nested levels, the next question is how many downstream calls should be allowed at the same time.
The companion repository at github.com/salgotraja/project-loom has runnable examples for every post in this series. The README covers build setup and the scripts used to reproduce the checks.