Structured Concurrency: Why JEP 505 Changes How We Think About Cancellation, Not Just Syntax
A request comes in. Your service forks two calls, one for the user and one for their orders. The user lookup fails after a few milliseconds. Your handler catches the exception, returns a clean error to the caller, and moves on. Meanwhile, the orders call is still out there, holding a connection, burning a thread, and quietly doing work for a request that no longer exists.
Nobody wrote a bug here. Every line did what it said. The problem is that the code never said the two calls belonged together. This article argues that JEP 505 is mostly about fixing that, and that treating it as a prettier way to start threads misses the point.
The Old Mental Model: A Bag of Threads
Classic Java concurrency hands you an ExecutorService and a handful of Future objects. You submit work, you get handles back, and you decide what to do with them. That freedom is exactly the trouble.
The relationship between the parent task and its subtasks lives only in the programmer’s head. The runtime has no idea that the orders call exists because of the user request. So when the parent fails, gets interrupted, or simply returns early, nothing forces the children to stop. As the JEP 505 text puts it in essence, the program is logically a tree of task and subtask relationships, but those relationships are not visible anywhere the platform can act on them.
Virtual threads made this sharper. Because a thread per I/O operation is now cheap, we fork more often, and so we leak more often. Cheap threads do not remove the need to manage them. They only remove the excuse for not forking.
What JEP 505 Actually Changed
Structured concurrency has been around for a while. It incubated in JDK 19 and 20 (JEP 428 and JEP 437), then entered preview in JDK 21 with JEP 453, and kept re-previewing through JDK 24. JEP 505 is the fifth preview, and it shipped in JDK 25.
The visible changes are small enough to fit in one sentence. You no longer call a constructor on StructuredTaskScope. You call a static open() factory and pass in a Joiner. The old ShutdownOnFailure and ShutdownOnSuccess subclasses are gone, replaced by factory methods on Joiner. You can read the original description in the integration pull request.
If you stop there, it looks like an API facelift. However, look at what actually moved. Policy used to be something you inherited by subclassing. Now it is something you choose by passing a value. That sounds minor, but it makes cancellation a declared property of the scope instead of a behavior hidden in an override.
One Lifetime, One Cancellation Domain
Here is the shift in a single line: a scope is a task tree with exactly one lifetime. You open it in a try-with-resources block, you fork subtasks inside it, you join them as a unit, and the block ends only when every subtask is finished or cancelled. Nothing escapes.
That structure gives cancellation something it never had before, which is a place to live. When the scope is cancelled, it stops new threads from being started in it and interrupts the threads running unfinished subtasks, according to the JDK 25 documentation. Cancellation is no longer a message you hope each child receives. It is a property of the whole tree.
Who Decides When to Cancel?
The Joiner decides. Every time a subtask completes, successfully or not, the joiner’s onComplete method returns a boolean, and returning true cancels the scope. That one boolean is the entire cancellation policy. Different joiners simply return true at different moments.
| Joiner | Cancels the scope when | What join() gives you |
|---|---|---|
allSuccessfulOrThrow() | the first subtask fails | All results if everything succeeds, otherwise an exception |
anySuccessfulResultOrThrow() | the first subtask succeeds | That first result, or an exception if all fail |
awaitAllSuccessfulOrThrow() | the first subtask fails | Nothing on success, the first failure otherwise |
awaitAll() | never | Nothing, you inspect each subtask yourself |
allUntil(predicate) | the predicate returns true for a completed subtask | All subtasks, including cancelled ones |
Notice what this table says about the design. “Fail fast,” “race to the first answer,” and “collect everything” are not three different threading techniques. They are three different answers to the same cancellation question. That is why this change is semantic rather than syntactic.
Does It Actually Work? A Small Experiment
Claims about cancellation are cheap, so I measured it. I ran a small harness on Temurin 25.0.1 with preview features enabled. One task fails after 50 milliseconds. A sibling task is still running. I compared a plain ExecutorService with a StructuredTaskScope using awaitAllSuccessfulOrThrow(), and I tried two kinds of sibling: one that blocks in Thread.sleep for 2000 ms, and one that spins in a CPU loop for 600 ms without ever checking for interruption.
Each configuration ran nine times, I discarded the first two as warmup, and I report the median of the remaining seven. The machine was a single-vCPU sandbox, and I used platform threads for all runs. With virtual threads on one core, the CPU-bound sibling monopolized the only carrier thread and delayed the failing task itself, which would have muddied the comparison.
Time from the failure until all work has stopped

The blocking case is the clean win. With the unstructured executor, the orphaned sibling kept running for about 1950 ms after the failure, which is simply the rest of its sleep. Nobody cancelled it, because nobody was responsible for it. With the scope, the sibling was interrupted in well under a millisecond.
The Catch: Cancellation Is Still Cooperative
Now look at the second pair of bars. The CPU-bound sibling ran for about 550 ms after the failure in both setups. Structured concurrency did not make it stop, because interruption in Java is a request, not a kill switch. A loop that never checks the interrupt flag and never blocks will not notice.
There is a subtler consequence, and it is the part I find most interesting. The scope guarantees that no subtask outlives it. So when a sibling ignores cancellation, the guarantee turns into a wait. In my runs, join() threw almost instantly, but close() did not return until the stubborn sibling finished.
Structured scope: when the caller sees the failure vs when the scope ends

This is not a flaw so much as the bill coming due. Structured concurrency trades the silent leak for a visible wait. You can argue that a visible wait is the better failure mode, because you can see it in a thread dump and fix the uncooperative code. In practice, though, it means that the quality of your cancellation now depends on whether your tasks respond to interruption.
| Question | Bag of threads | Task tree with one lifetime |
|---|---|---|
| Who owns a subtask? | Whoever holds the Future | The scope that forked it |
| What happens on failure? | Siblings keep running unless cancelled by hand | The joiner decides, and the scope cancels the rest |
| Can work outlive the request? | Yes, silently | No, the scope waits for it |
| Where does the hierarchy show up? | Only in the author’s head | In the runtime, including thread dumps |
What Happened After JEP 505
The API did not stop moving. JEP 525, the sixth preview in JDK 26, added an onTimeout() method to Joiner so a custom joiner can return a partial result when a timeout expires instead of always throwing. It also changed allSuccessfulOrThrow() to return a list of results rather than a stream of subtasks, and renamed anySuccessfulResultOrThrow() to anySuccessfulOrThrow(). The InfoQ coverage walks through the timeout change in detail.
The JDK 27 documentation now points to JEP 533 as its background reference. All of this is still preview, so you need --enable-preview and you should expect further edits. Notice, though, which direction the edits go. They are about timeouts and result shapes, which are cancellation and outcome questions, not about the core idea of one scope with one lifetime.
Checklist: Thinking in Task Trees
- Name the parent task, then list every subtask that exists only because of it. Those belong in one scope.
- Pick the joiner first. Decide whether a failure, a success, or a predicate should cancel the rest.
- Audit each subtask for interruption. A CPU loop with no interrupt check will make
close()wait. - Set a timeout on the scope rather than on individual calls, so the whole tree shares one deadline.
- Look at a thread dump after a forced failure and confirm nothing from the scope is still running.
What We Learned
JEP 505 looks like a change to how you start threads, but its real contribution is making a parent and its subtasks a single unit with one lifetime. That unit gives cancellation a home: the joiner decides when to cancel, the scope interrupts what is still running, and no subtask is allowed to outlive the block. In my JDK 25 measurements, a blocking sibling that kept running for about 1950 ms under a plain executor stopped in under a millisecond inside a scope, while a CPU-bound sibling that ignored interruption ran its full course either way and made close() wait for it. So the lesson is two-sided. Structured concurrency removes the silent leak, but it cannot make uncooperative code cooperative, which means the next thing to audit in your codebase is how your tasks respond to interruption.

