Structured Concurrency — Joiners
Structured Concurrency scopes give you a clean way to run multiple subtasks and then join them with a clear policy. That policy is defined by a Joiner.
To understand how Joiners change the behavior of a scope, let’s look at two short examples that run the same subtasks but use different Joiners.
Default Joiner: awaitAllSuccessfulOrThrow()
When you call StructuredTaskScope.open() without arguments, the scope uses the default Joiner: awaitAllSuccessfulOrThrow().
This Joiner waits for all subtasks to succeed. If any fails, it cancels the scope and makes join() throw a FailedException.
void handleAwaitAllSuccessfulOrThrow() throws Exception {
try (var scope = StructuredTaskScope.open()) { // default Joiner
StructuredTaskScope.Subtask<String> users = scope
.fork(() -> executeExternalCall(Behavior.run(”fetch user”, 500)));
StructuredTaskScope.Subtask<String> account = scope
.fork(() -> executeExternalCall(Behavior.fail(”fetch account”, 500)));
Void join = scope.join(); // Throws if any subtask fails
System.out.println(formatResults(users, account));
} catch (StructuredTaskScope.FailedException e) {
log(”Task failed: “ + e.getCause().getMessage());
throw (Exception) e.getCause();
}
}Custom output:
13:40:35.999 VThread[#20]: Initialize - fetch user
13:40:36.000 VThread[#22]: Initialize - fetch account
13:40:36.000 VThread[#22]: Ending with Error - fetch account
13:40:36.002 main : Task failed: Internal Error from outside callWhat happened?
One subtask succeeded (
fetch user)One failed (
fetch account)The default Joiner requires all to succeed → so the scope throws
No successful result is returned; the failure wins
This is the strictest Joiner: “all or nothing.”
Other Useful Joiners
anySuccessfulResultOrThrow()
Waits for the first successful subtask and returns its value.
If all subtasks fail, join() throws.
awaitAll()
Wait for all subtasks, even if some fail. Never throws.
Use it when failures should be inspected manually.
allSuccessfulOrThrow()
Collect all successful subtasks into a Stream<Subtask<T>>.
Throw if any subtask fails.
allUntil(Predicate)
Cancel the scope when the predicate matches a completed subtask.
Useful for custom “stop-early” logic.
Custom Joiners
You can implement your own by overriding:
onFork(Subtask)onComplete(Subtask)result()
This lets you build advanced policies (e.g., “return when 2 results succeed” or “fail only if these specific tasks fail”).
Conclusion
Joiners define how a structured concurrency scope waits, cancels, and returns results.
By switching only the Joiner, and keeping the subtasks identical, you can change whether the scope:
fails fast,
returns the first success,
collects all results, or
waits for everything regardless of failure.
And the best part: we can build our own Joiner.
In the next post, we’ll walk through creating a custom Joiner step by step, showing how to encode more advanced joining policies.
👉 Subscribe to get the next post:
References
Java API documentation:
https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/StructuredTaskScope.Joiner.html


