Contents

With CompletableFuture or ExecutorService, it is easy to launch subtasks that outlive the code that launched them — leading to thread leaks, difficult error handling, and partial results.

// Unstructured — what happens if fetchUser fails? CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id)); CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(id)); // If fetchUser throws, fetchOrder keeps running — wasting resources // If we cancel one, the other is not automatically cancelled // Error handling requires careful chaining — easy to miss exceptions User user = userFuture.get(); Order order = orderFuture.get();

Structured concurrency solves this: the scope owns all subtasks. When the scope closes (by either success, failure, or explicit shutdown), all outstanding subtasks are cancelled automatically.

ShutdownOnFailure cancels all remaining tasks as soon as one fails. When join() returns, either all tasks succeeded or an exception is propagated.

record UserProfile(User user, List<Order> orders, List<Address> addresses) {} UserProfile fetchProfile(long userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { // Fork subtasks — each runs on a virtual thread StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId)); StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId)); StructuredTaskScope.Subtask<List<Address>> addressTask = scope.fork(() -> fetchAddresses(userId)); // Wait for all subtasks — or for any to fail scope.join() .throwIfFailed(); // re-throws the first failure as an exception // All succeeded — safe to call result() return new UserProfile( userTask.get(), ordersTask.get(), addressTask.get() ); } // On scope close: any still-running subtasks are cancelled automatically } All three subtasks run concurrently on virtual threads. If fetchOrders throws, the scope immediately cancels fetchUser and fetchAddresses, then throwIfFailed() re-throws the exception.

ShutdownOnSuccess cancels all remaining tasks as soon as one succeeds. This is ideal for racing multiple equivalent sources and using whichever responds first.

String fetchFromFastest(String key) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) { // Race primary vs replica database scope.fork(() -> primaryDb.get(key)); scope.fork(() -> replicaDb.get(key)); scope.fork(() -> cache.get(key)); // also try cache scope.join(); // waits until first succeeds (or all fail) return scope.result(); // returns the first successful result } } // Another example — try multiple geocoding services Coordinate geocode(String address) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Coordinate>()) { scope.fork(() -> googleMaps.geocode(address)); scope.fork(() -> openStreetMap.geocode(address)); scope.fork(() -> hereApi.geocode(address)); scope.join(); return scope.result(); } }

StructuredTaskScope.ShutdownOnFailure cancels all remaining subtasks as soon as any one of them throws an exception. Calling scope.throwIfFailed() after join() re-throws that first exception, making error propagation explicit and predictable. ShutdownOnSuccess does the opposite: it cancels remaining tasks as soon as the first subtask completes successfully. Both policies eliminate the "fire and forget" problem common with CompletableFuture, where a failing subtask can go undetected if its exception is not explicitly chained. The scope's lifetime guarantees that by the time the try-with-resources block exits, all subtasks have either completed or been interrupted.

// Subtask states after scope.join(): // UNAVAILABLE — not started // SUCCESS — completed successfully (call .get() to retrieve result) // FAILED — threw an exception (call .exception() to get it) try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var task1 = scope.fork(() -> step1()); var task2 = scope.fork(() -> step2()); var task3 = scope.fork(() -> step3()); scope.join().throwIfFailed(e -> new RuntimeException("Pipeline failed", e)); // Inspect individual results System.out.println("Step1: " + task1.get()); System.out.println("Step2: " + task2.get()); System.out.println("Step3: " + task3.get()); } // Manual cancellation — scope.shutdown() can be called early try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var task = scope.fork(() -> longRunningOp()); // Timeout: cancel after 2 seconds scope.joinUntil(Instant.now().plusSeconds(2)); if (task.state() != StructuredTaskScope.Subtask.State.SUCCESS) { throw new TimeoutException("Operation timed out"); } return task.get(); } Structured concurrency requires enabling preview features in Java 21 (--enable-preview). It is expected to be finalized in a later Java release. Check the current Java version's status before using in production.
// Clean pattern: parallel fetch with structured concurrency record PageData(String content, List<String> links, Map<String, String> metadata) {} PageData scrapePage(URL url) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var content = scope.fork(() -> fetchContent(url)); var links = scope.fork(() -> extractLinks(url)); var metadata = scope.fork(() -> fetchMetadata(url)); scope.join().throwIfFailed(); return new PageData(content.get(), links.get(), metadata.get()); } }