Java
    August 24, 202619 min read

    Structured Concurrency in Java 24: The Right Way to Manage Concurrent Tasks

    Java 24 made Structured Concurrency GA. Here's what it is, why it matters alongside Virtual Threads, and how to use StructuredTaskScope to write safe, readable concurrent code.

    Share
    NOTE

    Structured Concurrency is GA in Java 24 (JEP 499, released March 2025). It was in preview from Java 21–23 — if you're on an earlier version, add --enable-preview to your compiler and JVM flags. On Java 24+, no flag is needed.

    Virtual threads changed how Java handles I/O-bound concurrency — instead of blocking a platform thread, a virtual thread parks cheaply and yields the carrier thread to other work. If you haven't read that story yet, start with the Java 21 Virtual Threads guide.

    But virtual threads left one problem unsolved: how do you coordinate a group of concurrent tasks cleanly? Structured Concurrency is the answer. It is the API that makes writing parallel code feel like writing sequential code — and it is finally production-ready in Java 24.


    The Problem Virtual Threads Didn't Solve

    Imagine fetching a user profile and their recent orders in parallel, then combining the results:

    // The old way — with ExecutorService
    ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    
    Future<User> userFuture = executor.submit(() -> fetchUser(userId));
    Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
    
    try {
        User user = userFuture.get();
        List<Order> orders = ordersFuture.get();
        return new Dashboard(user, orders);
    } catch (ExecutionException e) {
        userFuture.cancel(true);   // easy to forget
        ordersFuture.cancel(true); // easy to forget
        throw e;
    }

    Three problems with this:

    1. Thread leaks on partial failure. If fetchUser throws, the ordersFuture keeps running unless you explicitly cancel it. Forget one .cancel() call and you leak a thread.
    2. Error propagation is manual. You unwrap ExecutionException, re-throw, hope nothing gets swallowed.
    3. No lifetime guarantee. There's nothing stopping a task from outliving the code that created it. Debugging becomes archaeology.

    Structured Concurrency solves all three with one rule: a subtask cannot outlive the scope that forked it.


    What Structured Concurrency Is

    The core mental model is a strict parent-child lifetime:

    flowchart TD A[Open StructuredTaskScope] --> B[fork Task A] A --> C[fork Task B] B --> D[join — wait for all tasks] C --> D D --> E{Policy check} E -->|All succeeded| F[Harvest results & close scope] E -->|Failure detected| G[Cancel remaining tasks] G --> H[Propagate exception & close scope]

    When the scope closes — either normally or via exception — every forked task has either completed or been cancelled. No loose threads, no orphaned work.


    StructuredTaskScope Basics

    The API uses try-with-resources. You open a scope, fork tasks into it, join, then read results:

    import java.util.concurrent.StructuredTaskScope;
    
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    
        StructuredTaskScope.Subtask<User> userTask =
            scope.fork(() -> fetchUser(userId));
    
        StructuredTaskScope.Subtask<List<Order>> ordersTask =
            scope.fork(() -> fetchOrders(userId));
    
        scope.join()           // wait for both tasks to complete
             .throwIfFailed(); // rethrow the first exception if any task failed
    
        // Both tasks succeeded — safe to read results
        return new Dashboard(userTask.get(), ordersTask.get());
    }
    // Scope closes here: all tasks are done or cancelled, guaranteed

    scope.fork() returns a Subtask handle. You read subtask.get() only after the join — the API enforces this at runtime, so you can't accidentally read a result before its task finishes.


    ShutdownOnFailure — Fail Fast

    ShutdownOnFailure implements a simple policy: the first task to fail cancels all the others. This is the policy you want for most parallel-fetch patterns where all results are required.

    record ProductPage(Product product, List<Review> reviews, int stockCount) {}
    
    ProductPage loadProductPage(String productId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    
            var productTask  = scope.fork(() -> productService.get(productId));
            var reviewsTask  = scope.fork(() -> reviewService.getTop(productId, 5));
            var inventoryTask = scope.fork(() -> inventoryService.getStock(productId));
    
            scope.join().throwIfFailed();
    
            return new ProductPage(
                productTask.get(),
                reviewsTask.get(),
                inventoryTask.get()
            );
        }
    }

    If reviewService.getTop() throws, the scope immediately sends an interrupt to the other two tasks, waits for them to stop, then rethrows the original exception from .throwIfFailed(). No leaked threads, no partially-assembled result.


    ShutdownOnSuccess — First Result Wins

    ShutdownOnSuccess is the opposite policy: the first task to succeed cancels all the others. Use this for redundant calls where you want the fastest response — hedged requests, fallback endpoints, or racing two LLM providers.

    String getAiResponse(String prompt) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    
            scope.fork(() -> claudeClient.complete(prompt));   // primary
            scope.fork(() -> fallbackClient.complete(prompt)); // backup
    
            scope.join(); // waits until one succeeds (or both fail)
    
            return scope.result(); // the winning response
        }
    }

    scope.result() returns the first successful value. If both tasks fail, it throws a ExecutionException wrapping the last failure. This pattern is valuable when tail latency matters — the slow response gets cancelled the moment a fast one arrives.


    Custom Policies

    The two built-in policies cover most cases. For anything else, extend StructuredTaskScope directly and override handleComplete:

    // Collect N successful results, then cancel the rest
    class QuorumScope<T> extends StructuredTaskScope<T> {
        private final int quorum;
        private final List<T> results = new CopyOnWriteArrayList<>();
    
        QuorumScope(int quorum) { this.quorum = quorum; }
    
        @Override
        protected void handleComplete(Subtask<? extends T> subtask) {
            if (subtask.state() == Subtask.State.SUCCESS) {
                results.add(subtask.get());
                if (results.size() >= quorum) shutdown(); // cancel remaining
            }
        }
    
        public List<T> results() {
            ensureOwnerAndJoined(); // enforced by the API
            return List.copyOf(results);
        }
    }
    
    // Usage: read from 2 out of 3 replicas, accept whichever 2 respond first
    try (var scope = new QuorumScope<UserData>(2)) {
        scope.fork(() -> replicaA.read(userId));
        scope.fork(() -> replicaB.read(userId));
        scope.fork(() -> replicaC.read(userId));
        scope.join();
        return scope.results(); // at least 2 results, or fewer if only 1-2 replicas responded
    }

    ensureOwnerAndJoined() is provided by the base class and throws if called before join() completes — it enforces the contract that results are only readable after the scope is done.


    What You Get for Free: Cancellation and Scoped Values

    Cancellation propagates automatically. When a scope shuts down, it sends an interrupt to every forked task. Tasks that are blocking on I/O (virtual threads parked on socket reads, for example) wake up and exit cleanly. You don't write any cancellation logic — you just let the scope do it.

    Scoped Values flow into subtasks automatically. Scoped Values (also GA in Java 24, JEP 487) are the replacement for ThreadLocal in a virtual thread world. Any scoped value bound in the parent scope is readable inside every forked subtask:

    static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    
    // In a request handler:
    ScopedValue.runWhere(REQUEST_ID, traceId, () -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> {
                // REQUEST_ID.get() works here — no explicit passing needed
                logger.info("[{}] Fetching user", REQUEST_ID.get());
                return userService.get(userId);
            });
            scope.join().throwIfFailed();
        }
    });

    No more threading a context object through every method signature just to carry a request ID into parallel work.


    Before vs After

    ExecutorService + CompletableFuture StructuredTaskScope
    Thread leak on failure Must manually cancel all futures Automatic — scope cancels on close
    Error propagation Unwrap ExecutionException manually .throwIfFailed() one call
    Task lifetime guarantee None — tasks can outlive the caller Enforced by the scope
    Result reads before join Possible (race condition) Blocked by API at runtime
    Code lines for parallel fetch + error handling ~20–30 lines ~8–10 lines
    Debugger / thread dump readability Flat list of threads Subtask tree matches call structure

    The thread dump improvement alone is significant in production: with structured concurrency, a thread dump shows the scope hierarchy, making it immediately clear which tasks belong to which request.


    Putting It Together: Parallel AI Calls in a Spring Boot Endpoint

    Spring Boot 3.2+ enables virtual threads via spring.threads.virtual.enabled=true. With that in place, every request runs on a virtual thread, and StructuredTaskScope fits in naturally:

    @RestController
    @RequiredArgsConstructor
    public class InsightController {
    
        private final UserService userService;
        private final ClaudeService claudeService;
        private final MetricsService metricsService;
    
        @GetMapping("/insights/{userId}")
        public InsightResponse getInsights(@PathVariable String userId) throws Exception {
    
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    
                // Three independent calls — all run in parallel
                var userTask    = scope.fork(() -> userService.getProfile(userId));
                var aiTask      = scope.fork(() -> claudeService.generateInsight(userId));
                var metricsTask = scope.fork(() -> metricsService.getSummary(userId));
    
                scope.join().throwIfFailed();
    
                return new InsightResponse(
                    userTask.get(),
                    aiTask.get(),
                    metricsTask.get()
                );
            }
            // All three tasks done or cancelled before this line — no exceptions possible here
        }
    }

    Total latency = max(userService, claudeService, metricsService) instead of the sum. If the AI call takes 800 ms and the other two take 50 ms each, the endpoint returns in ~800 ms, not ~900 ms.

    Maven setup for Java 24:

    <properties>
        <java.version>24</java.version>
        <maven.compiler.source>24</maven.compiler.source>
        <maven.compiler.target>24</maven.compiler.target>
    </properties>

    application.properties for virtual threads:

    spring.threads.virtual.enabled=true

    Key Takeaways

    • Use ShutdownOnFailure when all parallel results are required — the default for most parallel-fetch patterns.
    • Use ShutdownOnSuccess when you're racing for the fastest response — hedged requests, fallback providers, redundant caches.
    • Extend StructuredTaskScope for custom join policies — quorum reads, majority-wins, collect-N.
    • The lifetime rule is the feature: a subtask cannot outlive its scope. No thread leaks, no orphaned work.
    • Scoped Values replace ThreadLocal for passing context into forked tasks — no explicit threading of parameters.
    • Java 24 required for GA. Java 21–23 need --enable-preview. For Spring Boot, pair with spring.threads.virtual.enabled=true.

    If you're already using virtual threads, Structured Concurrency is the natural next step. It takes the throughput wins from virtual threads and adds the coordination layer that makes concurrent code safe to write and straightforward to debug.

    Further reading:

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments