engnotes.dev
NotebookTopicsAbout

Subscribe

One email when a new post goes up. Nothing else.

one per post · no tracking · also on RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Project Loom9Structured Concurrency9Tail Latency & System Behavior4

Elsewhere

  • GitHub
  • X
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/structured-concurrency/part 1
Structured Concurrency · Part 1 of 9

What structured scopes actually catch

Most concurrency bugs in service code come from missing lifecycle, not missing parallelism. A scope that owns its forks is what makes task lifetime visible in the code that started them. The bugs that go away when scopes are right.

J
Jagdish Salgotra
2026-03-22·15 min read·~491 words

Series navigation

← PreviousN/A (start of series)
Next →What a missed deadline should do, and what it should not
Code repositoryproject-loom
#structured-concurrency
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

all posts

Keep reading · rest of the series

  • 2026-03-3010 min read
    Part 2
    What a missed deadline should do, and what it should not
  • 2026-04-0610 min read
    Part 3
    Cancelling siblings before they burn capacity
  • 2026-04-1210 min read
    Part 4
    Two workflow shapes that show up after fork-and-wait
  • 2026-04-1910 min read
    Part 5
    Why downstream capacity is the real ceiling on fan-out
Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 15 · ~15 left

Ask the post

Any answer points back at the paragraph it came from.

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.

Why Structured Concurrency Exists

Java has long supported concurrent programming through threads, executors, and futures. These tools are powerful, but request-level orchestration often becomes hard to reason about:

  • related tasks are launched in different places,
  • cancellation is inconsistent,
  • failure handling is fragmented,
  • cleanup is easy to miss.

Structured concurrency addresses this by making related concurrent work live inside an explicit scope with one lifecycle owner.

Core Model

Structured concurrency in Java 21 preview is centered on StructuredTaskScope.

A parent scope:

  • forks related subtasks,
  • waits for completion,
  • handles failure policy,
  • guarantees cleanup when the scope closes.

That structure makes task boundaries visible in code and easier to maintain.

Where Traditional Approaches Get Messy

Manual Thread Management

java
List<Thread> threads = new ArrayList<>();

for (int i = 0; i < 50; i++) {
    final int taskId = i;
    Thread thread = Thread.startVirtualThread(() -> {
        try {
            monitor.trackTask("basic-task-" + taskId, () -> {
                Thread.sleep(100 + ThreadLocalRandom.current().nextInt(200));
                return "Task " + taskId + " completed";
            });
        } catch (Exception e) {
            logger.info("Error occurred in task {}: {}", taskId, e.getMessage());
        }
    });
    threads.add(thread);
}

for (Thread thread : threads) {
    thread.join();
}

Ad-hoc Future Composition

java
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 can work, but failure and cancellation policy usually spreads across multiple call sites.

Java 21 Preview API: Basic Pattern

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> fetch1 = scope.fork(() -> fetchFromService1());
    Subtask<String> fetch2 = scope.fork(() -> fetchFromService2());
    Subtask<String> fetch3 = scope.fork(() -> fetchFromService3());

    scope.join();
    scope.throwIfFailed();

    String result = fetch1.get() + fetch2.get() + fetch3.get();
    logger.info("Combined result: {}", result);
}

Why this pattern is useful:

  • one scope owns all subtasks,
  • fail-fast semantics are explicit,
  • scope closure handles cleanup deterministically.

Common Java 21 Preview Limitations

In project code, the safe baseline always pairs join() with throwIfFailed():

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var future = scope.fork(task);
    scope.join();
    scope.throwIfFailed();
    return future.get();
}

Use a consistent review checklist for scope blocks:

  1. fork all related tasks inside one scope.
  2. join before reading results.
  3. throwIfFailed before get.
  4. keep side effects idempotent where possible.

Relationship with Virtual Threads

Structured concurrency is especially useful when tasks run on virtual threads (Project Loom, JEP 444). Virtual threads are final in Java 21, so they are safe to combine with this preview scoped API.

For I/O-bound fan-out work:

  • virtual threads keep blocking code straightforward,
  • structured scopes keep lifecycle/error policy explicit.

This is a strong combination for service orchestration. It is not a claim that all workloads become faster; CPU-bound behavior still depends on core count and scheduling limits.

Practical Scenarios

Service Aggregation

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var authService = scope.fork(() -> simulateServiceCall("auth-service", 100));
    var userService = scope.fork(() -> simulateServiceCall("user-service", 150));
    var notificationService = scope.fork(() -> simulateServiceCall("notification-service", 80));
    var analyticsService = scope.fork(() -> simulateServiceCall("analytics-service", 200));

    scope.join();
    scope.throwIfFailed();

    String result = String.format("Service Aggregation: %s, %s, %s, %s",
        authService.get(), userService.get(), notificationService.get(), analyticsService.get());
}

Parallel Repository Queries

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var userQuery = scope.fork(() -> simulateDbQuery("users", 150));
    var orderQuery = scope.fork(() -> simulateDbQuery("orders", 120));
    var productQuery = scope.fork(() -> simulateDbQuery("products", 180));

    scope.join();
    scope.throwIfFailed();

    String result = String.format("DB Results: %s, %s, %s",
        userQuery.get(), orderQuery.get(), productQuery.get());
}

When to Use (and Not Use)

Good fit:

  • I/O-bound fan-out/fan-in request paths,
  • orchestrating multiple dependency calls with shared failure policy,
  • flows that need reliable cancellation and cleanup behavior.

Lower value cases:

  • purely CPU-bound batch computation,
  • long-lived background workers not tied to request scope,
  • systems where preview-feature usage is not allowed.

Build Setup (Java 21 Preview)

xml
<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>2.0.13</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.13</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>21</source>
                <target>21</target>
                <compilerArgs>--enable-preview</compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

Also run with preview enabled:

bash
java --enable-preview ...

Preview Limitations and Upgrade Path

Structured concurrency was preview in Java 21 and remains preview in Java 25, with significant API evolution. Treat Java 21 examples as version-specific and test thoroughly before production rollout.

Part 9 in this series covers migration changes from Java 21 preview APIs to Java 25 preview APIs.

Resources

  • JEP 453: Structured Concurrency (Preview)
  • JEP 444: Virtual Threads
  • Java 21 API: StructuredTaskScope (Preview)