Published on
· Updated

Java 26 Structured Concurrency in Spring Boot with Virtual Threads

Authors

Java structured concurrency treats several related concurrent calls as one operation. A request can fork independent subtasks, wait for a defined outcome, cancel work that is no longer needed, and leave the lexical scope only after its child threads finish.

That model is a strong fit for a Spring Boot endpoint that must fetch a customer, recent orders, and recommendations in parallel. It is not a background-job API, a distributed transaction, or a reason to remove downstream concurrency limits.

Java 26 includes StructuredTaskScope as a sixth preview under JEP 525. The API is fully implemented but not permanent, and it changed between JDK 25 and JDK 26. Production adoption therefore requires preview flags, release-specific tests, and an explicit JDK upgrade plan.

Version note

Code in this article targets JDK 26 and Spring Boot 4.1 as of August 10, 2026. Spring Boot 4.1 supports Java through version 26. JDK 25 examples can differ: JDK 26 renamed anySuccessfulResultOrThrow() to anySuccessfulOrThrow() and refined timeout and joiner behavior.

TL;DR

  • Use StructuredTaskScope for related subtasks that must finish or be cancelled before a request continues.
  • The default scope creates a virtual thread per subtask; spring.threads.virtual.enabled is not required for that scope behavior.
  • Put a total deadline on the scope and shorter timeouts on every network client.
  • Cancellation interrupts unfinished subtasks; libraries that ignore interruption can delay scope closure.
  • Spring transaction, security, MDC, and other thread-local state do not automatically become one shared context across subtasks.
  • The API is preview. Compile, test, and run with --enable-preview, and expect migration work on a future JDK.

Structured concurrency solves a lifetime problem

An unstructured fan-out often looks like this:

CompletableFuture<Customer> customer = supplyAsync(() -> customerClient.get(id));
CompletableFuture<List<Order>> orders = supplyAsync(() -> orderClient.recent(id));

return customer.thenCombine(orders, CustomerPage::new).join();

This can work, but the code must separately answer:

  • Which executor owns each task?
  • What happens to the orders call if the customer call fails?
  • Does a request timeout cancel both tasks?
  • Can either task continue after the HTTP request has ended?
  • Where is the exception unwrapped and translated?
  • Which context reaches the executor threads?

A task scope ties those questions to one block:

open scope
  -> fork customer
  -> fork orders
  -> join according to one policy
  -> read successful results
close scope after every child thread finishes

The parent cannot accidentally return while a child from that scope is still running.

Know what it does not solve

Structured concurrency does not provide:

  • durable execution after a process restart;
  • a queue for fire-and-forget work;
  • atomic commits across subtask transactions;
  • automatic retries or circuit breaking;
  • extra database connections or remote API capacity;
  • faster execution for CPU-bound work merely because virtual threads are used;
  • forced termination of code that ignores interruption.

Use a workflow engine, broker, scheduler, or durable job store for work that should outlive the request. Use a transactional outbox when a database commit must cause a message later.

Enable the JDK 26 preview API

Preview code must be compiled and run on the matching JDK release with preview enabled.

For Maven:

<properties>
    <java.version>26</java.version>
    <maven.compiler.release>26</maven.compiler.release>
</properties>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <enablePreview>true</enablePreview>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <argLine>--enable-preview</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

Run the packaged application with the same flag:

java --enable-preview -jar app.jar

For Gradle Kotlin DSL:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(26)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.compilerArgs.add("--enable-preview")
}

tasks.withType<Test>().configureEach {
    jvmArgs("--enable-preview")
}

tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
    jvmArgs("--enable-preview")
}

Apply the runtime flag in containers, deployment manifests, integration tests, and local launch configurations. A preview-compiled class is tied to that JDK preview release; a later JDK may require source changes and recompilation.

Implement fail-fast request fan-out

Assume three blocking clients with explicit connection and response timeouts:

public interface CustomerClient {
    Customer get(UUID customerId);
}

public interface OrderClient {
    List<OrderSummary> recent(UUID customerId);
}

public interface RecommendationClient {
    List<Recommendation> forCustomer(UUID customerId);
}

Fork them inside a scope:

@Service
public class CustomerPageService {

    private final CustomerClient customers;
    private final OrderClient orders;
    private final RecommendationClient recommendations;

    public CustomerPageService(
        CustomerClient customers,
        OrderClient orders,
        RecommendationClient recommendations
    ) {
        this.customers = customers;
        this.orders = orders;
        this.recommendations = recommendations;
    }

    public CustomerPage load(UUID customerId) {
        try (var scope = StructuredTaskScope.open()) {
            Subtask<Customer> customer = scope.fork(
                () -> customers.get(customerId)
            );
            Subtask<List<OrderSummary>> recentOrders = scope.fork(
                () -> orders.recent(customerId)
            );
            Subtask<List<Recommendation>> suggestions = scope.fork(
                () -> recommendations.forCustomer(customerId)
            );

            scope.join();

            return new CustomerPage(
                customer.get(),
                recentOrders.get(),
                suggestions.get()
            );
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new RequestCancelled(interrupted);
        } catch (StructuredTaskScope.FailedException failed) {
            throw mapFailure(failed.getCause());
        }
    }
}

StructuredTaskScope.open() uses a fail-fast policy: if one subtask fails, unfinished siblings are cancelled through interruption, and join() throws FailedException. Results can be read only after a successful join.

The default configuration creates a new virtual thread for each subtask. That is a property of the task scope itself. Spring Boot's spring.threads.virtual.enabled=true separately changes Boot-managed executors and server integrations; it is useful for a broader virtual-thread deployment but is not required for this code to fork virtual threads.

Put a deadline on the whole operation

Independent client timeouts do not enforce one total request budget. Three calls with one-second timeouts can still exceed a one-second endpoint objective because of scheduling, retries, and sequential work around them.

Open a JDK 26 scope with an overall timeout:

private static final Duration PAGE_BUDGET = Duration.ofMillis(750);

public CustomerPage loadWithDeadline(UUID customerId) {
    ThreadFactory threads = Thread.ofVirtual()
        .name("customer-page-", 0)
        .factory();

    try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Object>awaitAllSuccessfulOrThrow(),
        configuration -> configuration
            .withThreadFactory(threads)
            .withTimeout(PAGE_BUDGET)
    )) {
        Subtask<Customer> customer = scope.fork(
            () -> customers.get(customerId)
        );
        Subtask<List<OrderSummary>> recentOrders = scope.fork(
            () -> orders.recent(customerId)
        );
        Subtask<List<Recommendation>> suggestions = scope.fork(
            () -> recommendations.forCustomer(customerId)
        );

        scope.join();

        return new CustomerPage(
            customer.get(),
            recentOrders.get(),
            suggestions.get()
        );
    } catch (StructuredTaskScope.TimeoutException timeout) {
        throw new UpstreamDeadlineExceeded(PAGE_BUDGET, timeout);
    } catch (InterruptedException interrupted) {
        Thread.currentThread().interrupt();
        throw new RequestCancelled(interrupted);
    } catch (StructuredTaskScope.FailedException failed) {
        throw mapFailure(failed.getCause());
    }
}

The scope timeout starts when the scope opens. When it expires, the scope is cancelled and unfinished subtask threads are interrupted.

That interruption is cooperative. Oracle's API documentation states that close() waits until every subtask thread finishes, even after cancellation. A JDBC driver, HTTP client, native call, or library that does not react promptly to interruption can keep the scope from closing.

Always configure downstream timeouts too:

  • connection acquisition timeout;
  • TCP connection timeout;
  • TLS handshake timeout where supported;
  • response or read timeout;
  • database query and statement timeout;
  • bounded retry time;
  • a value shorter than the remaining endpoint budget.

The scope deadline coordinates tasks; it cannot repair a client with unbounded blocking.

Model required and optional data explicitly

Fail-fast is right when every result is required. If recommendations are optional, convert only that failure into a successful fallback inside its subtask:

Subtask<List<Recommendation>> suggestions = scope.fork(() -> {
    try {
        return recommendations.forCustomer(customerId);
    } catch (RecommendationUnavailable unavailable) {
        return List.of();
    }
});

Do not catch Throwable or every runtime exception. A malformed response, programming bug, authentication failure, and temporary timeout do not necessarily deserve the same fallback.

Make the response contract visible:

public record CustomerPage(
    Customer customer,
    List<OrderSummary> recentOrders,
    List<Recommendation> recommendations,
    Set<String> degradedComponents
) {}

Returning an empty list without a degradation signal can make an outage look like legitimate “no recommendations” data.

Race equivalent providers for the first success

JDK 26's anySuccessfulOrThrow() joiner returns the first successful result and cancels unfinished alternatives:

public ExchangeRate fastestRate(CurrencyPair pair) {
    try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<ExchangeRate>anySuccessfulOrThrow()
    )) {
        scope.fork(() -> primaryRates.get(pair));
        scope.fork(() -> secondaryRates.get(pair));

        return scope.join();
    } catch (InterruptedException interrupted) {
        Thread.currentThread().interrupt();
        throw new RequestCancelled(interrupted);
    } catch (StructuredTaskScope.FailedException failed) {
        throw new RateUnavailable(pair, failed.getCause());
    }
}

Use this only when both calls are safe to execute concurrently and cancelling the loser is acceptable. Hedged requests increase downstream traffic and can amplify an incident. Rate-limit them, add a delay before the hedge when appropriate, and never race non-idempotent writes.

Keep Spring transactions out of forked thread assumptions

Spring's imperative transaction context is normally thread-bound. A transaction opened on the controller or service thread is not one shared transaction across virtual-thread subtasks.

This is unsafe reasoning:

@Transactional
public Result updateSeveralThings() {
    // Fork three repository calls and expect one atomic transaction.
}

Each subtask runs on another thread. Depending on how the repositories and proxied services are called, it may have no transaction or open an independent transaction. Failure of one subtask cannot roll back a commit already completed by another.

Recommended boundaries:

  • use structured concurrency for independent remote reads or calculations;
  • complete parallel reads, then perform one database write on the owner thread;
  • if subtasks deliberately own separate transactions, model compensation and partial failure as a distributed workflow;
  • never share an EntityManager or mutable JPA entity across subtask threads.

The same caution applies to thread-bound security context, request attributes, MDC, locale, and observability state. Pass required immutable values explicitly or use a propagation mechanism that is documented and tested for the threads you create.

JDK scoped values are inherited by structured subtasks, but ScopedValue is also a preview API in current releases. It does not automatically convert Spring's existing ThreadLocal contexts.

Bound downstream concurrency

Virtual threads make blocked Java threads inexpensive. They do not make these resources unlimited:

  • HikariCP database connections;
  • HTTP connection pools;
  • remote service worker capacity;
  • Kafka partitions;
  • file descriptors;
  • rate limits;
  • memory held by requests and responses.

If one endpoint forks three calls and 5,000 requests arrive, it can create 15,000 downstream calls. Use bulkheads or semaphores sized to the dependency, admission control at the endpoint, and timeouts that release capacity quickly.

public final class BoundedCustomerClient implements CustomerClient {

    private final Semaphore permits = new Semaphore(100);
    private final CustomerClient delegate;

    public BoundedCustomerClient(CustomerClient delegate) {
        this.delegate = delegate;
    }

    @Override
    public Customer get(UUID customerId) {
        try {
            if (!permits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
                throw new CustomerServiceOverloaded();
            }
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
            throw new RequestCancelled(interrupted);
        }
        try {
            return delegate.get(customerId);
        } finally {
            permits.release();
        }
    }
}

Place the limit around the scarce dependency, not around virtual-thread creation.

Understand virtual-thread pinning on modern JDKs

Advice written for JDK 21 often says every long synchronized block pins its virtual thread to a carrier thread. JEP 491 changed that in JDK 24: virtual threads can be unmounted while blocking inside synchronized code.

Pinning can still occur in some native or foreign-function situations, and dependency behavior still matters. More importantly, virtual threads do not remove lock contention. A synchronized bottleneck can serialize requests even when it no longer pins carriers.

Use Java Flight Recorder, thread dumps, and load tests on the deployed JDK instead of applying old Loom tuning advice unchanged.

Compare the main options

ToolLifetime relationshipThread modelGood fit
Sequential callsOne request blockCurrent threadDependent calls or low latency sensitivity
CompletableFutureManually composedChosen executorPipelines, reusable async stages, callback APIs
StructuredTaskScopeLexically bounded parent and childrenVirtual threads by defaultRequest fan-out with cancellation and one outcome policy
ReactorSubscription graphEvent-loop and schedulersEnd-to-end reactive applications and streaming
Message broker/workflowDurable process boundarySeparate workersWork that outlives a request or process

Structured concurrency is not automatically superior to CompletableFuture or Reactor. It is especially readable for synchronous-looking code that performs several independent blocking operations and must not leak child work.

Test behavior, not timing guesses

Avoid tests that assert “the method finishes in less than 100 ms.” Shared CI machines make them flaky. Coordinate fake clients with latches or barriers and verify state transitions.

Test:

  1. All subtasks start before any is released.
  2. One required failure interrupts unfinished siblings.
  3. An optional failure produces the documented fallback.
  4. The total scope timeout cancels the operation.
  5. A client that honors interruption exits promptly.
  6. A client that ignores interruption demonstrates the close-delay risk.
  7. The owner thread restores its interrupt flag after catching InterruptedException.
  8. Bulkhead saturation fails within its budget.
  9. No database transaction is assumed to cross subtask threads.
  10. The packaged application starts with preview enabled in the same container image used for deployment.

Also load-test downstream concurrency. Faster median response time is not a success if connection-pool waits, error rate, or remote saturation increases.

Observe one scope as one operation

Record:

  • total fan-out duration;
  • each dependency duration and outcome;
  • timeout and cancellation counts;
  • number of forked subtasks;
  • bulkhead acquisition failures;
  • tasks still slow to finish after cancellation;
  • response degradation flags.

JDK tooling can represent structured task relationships more clearly than unrelated executor tasks. Use named virtual threads where useful, Java Flight Recorder, and thread dumps during a failure drill.

Do not place customer IDs, request IDs, or URLs with dynamic path segments in metric labels. Keep bounded dependency names in metrics and high-cardinality identifiers in traces or structured logs.

Common mistakes

Copying old preview examples

StructuredTaskScope.ShutdownOnFailure examples target older previews. JDK 25 introduced the open and Joiner model, and JDK 26 refined it again. Use documentation for the exact JDK.

Enabling Boot virtual threads and assuming concurrency is structured

spring.threads.virtual.enabled=true changes executors; it does not automatically define parent-child task lifetimes or cancellation policy.

Treating timeout as forced termination

Cancellation interrupts subtasks. Scope closure still waits for them. Configure interruptible libraries and their native timeouts.

Forking JPA work inside one @Transactional method

The transaction context does not become one cross-thread transaction. Keep the atomic write on one thread.

Forking unbounded work per request

Virtual threads are cheap, but downstream work is not. Bound calls by dependency capacity.

Using preview without an upgrade budget

JDK 26 code may need changes on JDK 27 or when the API becomes permanent. Pin toolchains and rehearse upgrades.

Production checklist

  • Are the subtasks independent and tied to one request outcome?
  • Is preview usage approved, enabled in every build and runtime path, and covered by upgrade tests?
  • Is there one total deadline plus shorter client-specific timeouts?
  • Do clients react to interruption?
  • Are required and optional results modeled explicitly?
  • Is downstream concurrency bounded independently of virtual-thread count?
  • Are Spring transaction and thread-local context boundaries understood?
  • Are failures translated without hiding their cause?
  • Are cancellation, timeout, overload, and shutdown paths tested?
  • Can JFR, thread dumps, metrics, and traces explain a slow scope?

Structured concurrency is most valuable when it makes the request's lifetime obvious. Use it for a bounded tree of related work, keep durable jobs elsewhere, and treat preview API changes as part of the operational cost.

Official references