Structured Concurrency in Practice: ExecutorService vs StructuredTaskScope
Most Java concurrency bugs show up when something goes wrong, not when everything works. So let’s break one subtask on purpose and watch what each approach does about it.
A Quick Status Check
First, a warning about older tutorials. The idea has been around for a while: it first previewed in JDK 21, after two incubator rounds. Even so, structured concurrency is still a preview API, so you need --enable-preview to run it. JEP 533 delivers the seventh preview in JDK 27, which reached general availability on September 15, 2026.
Moreover, the API changed again in this round, and Dan Vega’s JDK 27 walkthrough warns that older examples are now stale. According to Loïc Mathieu’s overview, awaitAll() was removed and the failure-throwing joiners now raise ExecutionException. The good news is that JEP 543 proposes finalizing the API in JDK 28 without further change. That is only a proposal for now.
The Scenario
We will fetch three things at once: a profile, some orders and some offers. The profile and offers calls are healthy but slow, at two seconds each. However, the orders call fails after 200 milliseconds. The question is simple: what happens to the other two?
Side by Side
The ExecutorService Version
This is the classic pattern. It submits three tasks and then waits for each result in order.
static void withExecutor() throws Exception {
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> a = ex.submit(() -> work("profile", 2000, false));
Future<String> b = ex.submit(() -> work("orders", 200, true));
Future<String> c = ex.submit(() -> work("offers", 2000, false));
a.get();
b.get();
c.get();
}
}
The code looks reasonable, but it hides two traps. First, a.get() blocks for the full two seconds, even though the failure happened after 200 ms. Second, nothing ever cancels the healthy tasks. As the JEP 505 text explains, ExecutorService and Future allow unrestricted patterns, so you must coordinate lifetimes manually.
The StructuredTaskScope Version
Here, the same work lives inside a scope. The default scope waits for all subtasks to succeed, and it cancels the rest as soon as one fails.
static void withScope() throws Exception {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> work("profile", 2000, false));
scope.fork(() -> work("orders", 200, true));
scope.fork(() -> work("offers", 2000, false));
scope.join();
}
}
Notice what is missing: there are no futures to juggle, and no manual cancellation. As a result, the failure surfaces from join() right when it happens.
Run It Yourself
Save the file below as Compare.java. Both examples are in it, so one run prints both timelines. I tested it on Temurin 25.0.4.1 and Temurin 27+35.
import java.util.concurrent.*;
public class Compare {
static long t0;
static void log(String msg) {
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("%5d ms %s%n", ms, msg);
}
static String work(String name, int ms, boolean fail)
throws Exception {
log(name + " started");
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
log(name + " INTERRUPTED (cancelled)");
throw e;
}
if (fail) {
log(name + " FAILED");
throw new IllegalStateException(name + " broke");
}
log(name + " finished");
return name;
}
interface Body { void run() throws Exception; }
static void run(String title, Body body) {
System.out.println("== " + title);
t0 = System.nanoTime();
try {
body.run();
} catch (Exception e) {
log("parent caught " + e.getClass().getSimpleName()
+ ", cause: " + e.getCause());
}
log("parent done");
}
static void withExecutor() throws Exception {
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> a = ex.submit(() -> work("profile", 2000, false));
Future<String> b = ex.submit(() -> work("orders", 200, true));
Future<String> c = ex.submit(() -> work("offers", 2000, false));
a.get();
b.get();
c.get();
}
}
static void withScope() throws Exception {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> work("profile", 2000, false));
scope.fork(() -> work("orders", 200, true));
scope.fork(() -> work("offers", 2000, false));
scope.join();
}
}
public static void main(String[] args) {
run("ExecutorService", Compare::withExecutor);
run("StructuredTaskScope", Compare::withScope);
}
}
On JDK 27, run it like this. If you use JDK 25, change the number to 25.
java --enable-preview --source 27 Compare.java
Here is the timeline from one JDK 27 run, with times in milliseconds.
| Time | ExecutorService | StructuredTaskScope |
|---|---|---|
| About 200 to 250 ms | orders FAILED, but the parent is still blocked waiting on profile | orders FAILED, profile and offers INTERRUPTED, join() throws ExecutionException |
| About 2,050 ms | profile and offers finish with unused results, and the parent finally sees ExecutionException | Already finished at about 215 ms |
Times differ slightly between runs, and the executor’s first run starts a little later. The order of events is what matters.
What the Numbers Say
I repeated the run six times on JDK 27 and took the median. The parent code handled the failure after about 2,046 ms with the executor, but after about 213 ms with the scope.

The second chart shows the wasted work. With the executor, both healthy tasks ran their full two seconds, only to have their results thrown away. With the scope, they stopped at roughly 200 ms.

Two Details That Surprise People
First, switching the order of get() calls only helps a little. I tested a variant that calls b.get() first. It threw immediately at about 257 ms. Even so, the try-with-resources block still waited until about 2,058 ms, because closing the executor waits for running tasks.
Second, cancellation works through thread interruption. That is why the sleeping subtasks stopped so quickly. Therefore, your own subtasks should use interruptible calls, or check for interruption, to benefit fully.
Finally, the exception type depends on your JDK. In my test, JDK 25 threw FailedException from join(), while JDK 27 threw ExecutionException. In both cases, the original error was available as the cause.
Which One Should You Use?
| Situation | Better fit |
|---|---|
| One request fans out to several calls and needs all of them | StructuredTaskScope |
| You want fail-fast with automatic cancellation | StructuredTaskScope |
| Independent, long-lived or fire-and-forget jobs | ExecutorService |
| You need a shared, reusable pool | ExecutorService |
| You must ship on a stable API today, without preview flags | ExecutorService |
In short, the scope shines when subtasks belong to one parent task. The executor stays useful when tasks outlive any single caller.
What We Have Learned
When one subtask fails, ExecutorService leaves the rest running unless you cancel them yourself. In our test, the parent noticed the failure after about 2 seconds, and two healthy tasks finished work nobody needed. StructuredTaskScope cancelled them at about 200 ms and surfaced the error from join(), with the original exception as the cause. The trade-off is that the API is still in preview, and it changed between JDK 25 and 27, so pin your JDK and use --enable-preview. A final version is proposed for JDK 28. For now, use it for request-scoped fan-out, and keep executors for long-lived work.

