Concurrency Alternatives to Reactive Programming

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

Project Reactor is not the only way to write a scalable Spring Boot backend. This page lays out three concurrency models side by side — Project Reactor, Java Virtual Threads, and Kotlin Coroutines — for a team choosing (or migrating) a concurrency model for a service, covering each model’s execution model, pros/cons, a code example, and its own transaction-management story. Reactive Programming already covers Project Reactor in depth, so this page recaps it only briefly before focusing on the two newer alternatives.

Project Reactor (Recap)

Reactor pipelines are non-blocking and operator-composed: Mono/Flux publishers describe a chain of transformations during an assembly phase, and nothing runs until a subscriber requests data during subscription. See Reactive Programming for the full explanation of the assembly/subscription split, the operator set, backpressure, and schedulers.

Pros: no thread-per-request memory cost, even without virtual threads; mature backpressure handling; highly composable pipelines that scale down to a handful of event-loop threads. Cons: a steep learning curve; stack traces that no longer map cleanly onto the call site that triggered them; harder debugging in general; and a viral programming style — one blocking call anywhere in the chain breaks the whole thing.

@RestController
class OrderController {

    private final ReactiveOrderRepository repository;

    OrderController(ReactiveOrderRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/orders/{id}")
    Mono<Order> findById(@PathVariable String id) {
        return repository.findById(id)
                .switchIfEmpty(Mono.error(new OrderNotFoundException(id)));
    }
}

Java Virtual Threads

Virtual threads (Project Loom, JEP 444) are lightweight threads scheduled by the JVM rather than the OS, cheap enough to allocate one per request instead of pulling from a bounded platform-thread pool. See Virtual Threads for the full explanation of mounting, unmounting, and carrier threads.

Spring Boot opts a whole application into virtual threads with a single property, spring.threads.virtual.enabled=true, which swaps the embedded server’s request-handling executor for a virtual-thread-per-task executor — no code changes required (see the Spring Boot reference documentation).

Pros: existing blocking-style code, libraries, stack traces, and debuggers keep working completely unchanged — no reactive rewrite, and JDBC drivers work as-is. Cons: synchronized blocks and other pinning causes still occupy a platform-thread carrier for their duration, and not every blocking library or native call is virtual-thread-friendly yet.

spring.threads.virtual.enabled=true
@RestController
class OrderController {

    private final OrderRepository repository;   // ordinary blocking JDBC/JPA repository

    OrderController(OrderRepository repository) {
        this.repository = repository;
    }

    @GetMapping("/orders/{id}")
    Order findById(@PathVariable String id) {
        return repository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

Virtual Thread Transactions and ThreadLocal Affinity

Classic, ThreadLocal-based transaction management — TransactionSynchronizationManager/PlatformTransactionManager/JpaTransactionManager — keeps working unchanged under virtual threads, with no code or configuration changes to the transactional service layer itself. The reason is structural: each request runs on its own fresh virtual thread, created for that task rather than pulled from a reused platform-thread pool, so the ThreadLocal the transaction interceptor sets at the start of the method is never later seen by an unrelated request the way it could be if a ThreadLocal were accidentally left set on a reused pooled worker thread.

That said, three operational pitfalls are worth knowing:

  • ThreadLocal cleanup on cancellation/timeout. A virtual thread itself is not pooled, but whatever cleans up the transaction — the interceptor’s finally block — must still run for the ThreadLocal to be cleared. If a request is cancelled or times out in a way that skips normal completion, a leaked ThreadLocal entry is still possible, same as on a platform thread.

  • InheritableThreadLocal semantics differ from pooled platform threads. A pooled worker thread is created once and inherits its InheritableThreadLocal values from whoever created the pool, so a per-request value set by the submitting thread does not reach it. A fresh virtual thread created per task, by contrast, does copy InheritableThreadLocal values from its creator at creation time. This is a behavioral difference worth knowing, not automatically a downgrade in either direction — code that relied on pooled threads not inheriting a value may need re-checking.

  • Pinning caveats. A synchronized block anywhere in the transactional call path — a legacy DAO, a JDBC driver internal, a third-party library — pins the virtual thread to its carrier for that block’s duration, the same general mechanism covered in Virtual Threads's "Carrier Threads, Mounting, and Pinning" section. It matters specifically here because pinning can serialize concurrent transactional work onto a small pool of carrier threads under load.

@Service
class TransferService {

    private final AccountRepository accounts;

    TransferService(AccountRepository accounts) {
        this.accounts = accounts;
    }

    @Transactional
    void transferFunds(String from, String to, BigDecimal amount) {
        accounts.debit(from, amount);    // ordinary blocking calls, unchanged by virtual threads
        accounts.credit(to, amount);     // both run on the same ThreadLocal-bound connection
    }
}

Kotlin Coroutines

Kotlin coroutines let asynchronous code read like sequential code through suspend functions and structured concurrency, instead of an operator-composed pipeline. See Coroutines Basics, Flows, and Coroutine Context, Cancellation and Exceptions for the full explanation.

Both Spring MVC and Spring WebFlux controllers can declare suspend fun handlers directly, per Java or Kotlin for Spring Boot?'s own concurrency comparison. Under WebFlux, coroutines bridge to Reactor through kotlinx-coroutines-reactor, which converts Mono/Flux to and from suspending calls and Flow.

Pros: sequential-looking code without the reactive operator vocabulary; structured concurrency and cancellation built in; interop with Flow for streaming. Cons: needs Kotlin, which brings mixed-language setup cost (see Mixed Java + Kotlin Spring Boot Projects below); context-propagation pitfalls when bridging to and from Reactor’s Context (see the next section); a smaller talent pool than Java.

@RestController
class OrderController(private val repository: ReactiveOrderRepository) {

    @GetMapping("/orders/{id}")
    suspend fun findById(@PathVariable id: String): Order =
        repository.findById(id).awaitSingleOrNull()
            ?: throw OrderNotFoundException(id)
}

Coroutine Transactions and Reactive Transaction Management

@Transactional on a suspend function routes through Spring’s reactive transaction management, exactly like a method returning a Publisher — it needs a ReactiveTransactionManager bean (for example R2dbcTransactionManager), not the classic thread-bound PlatformTransactionManager. The reason parallels Reactor’s own story in Reactive Programming: a suspend function may resume on a different thread after suspension, so there is no single calling thread to bind a ThreadLocal to.

The working pattern uses TransactionalOperator.executeAndAwait to keep the whole unit of work inside one suspending call:

@Service
class TransferService(
    private val accounts: CoroutineAccountRepository,   // CoroutineCrudRepository: suspend functions
    private val txOperator: TransactionalOperator   // TransactionalOperator.create(reactiveTransactionManager)
) {

    suspend fun transferFunds(from: String, to: String, amount: BigDecimal) {
        txOperator.executeAndAwait {
            accounts.debit(from, amount)     // suspend calls: both actually execute, one unit of work
            accounts.credit(to, amount)
        }
    }
}

A known pitfall is losing the transaction/coroutine context across a suspension point — naive @Transactional + suspend combinations can silently drop the reactive Context the transaction relies on (spring-projects/spring-framework#28290). Written the wrong way, the second write escapes the transaction:

@Transactional
suspend fun brokenTransfer(from: String, to: String, amount: BigDecimal) {
    accounts.debit(from, amount)         // this one really does run, inside the transaction
    GlobalScope.launch {                 // WRONG: a new coroutine, its own context -- no transaction
        accounts.credit(to, amount)
    }
}

The fix is to keep the transactional unit of work inside one executeAndAwait block rather than spanning suspension points — or launching new coroutines — with ambient @Transactional state assumed to persist.

Mixed Java + Kotlin Spring Boot Projects

A real coroutines adoption is usually a mixed-language codebase — Kotlin for new services or modules alongside existing Java code — rather than a wholesale Kotlin rewrite. Both Maven and Gradle can build a single module containing both languages, but each needs explicit wiring.

Maven

kotlin-maven-plugin must compile before maven-compiler-plugin so that Kotlin code can reference Java classes and vice versa: bind the Kotlin plugin’s compile/test-compile executions to the same phases as the Java compiler, but declare kotlin-maven-plugin earlier in <plugins> than maven-compiler-plugin (or use explicit <executions> phase ordering) so the Kotlin compiler runs first. <sourceDirs> must cover both src/main/kotlin and src/main/java. The kotlin-spring and kotlin-jpa compiler plugins are configured through kotlin-maven-plugin’s `<compilerPlugins>/<pluginOptions> — they are the all-open/no-arg presets needed because Kotlin classes are final by default and Spring/JPA need to subclass @Component/@Configuration/@Entity classes, and they require the kotlin-maven-allopen and kotlin-maven-noarg plugin artifact dependencies. The -Xjsr305=strict compiler argument makes Kotlin treat Spring’s @Nullable/@NonNull JSR-305 annotations as strict null-safety information. At runtime the module also needs kotlin-stdlib (or kotlin-stdlib-jdk8), kotlin-reflect, and jackson-module-kotlin (so Jackson can serialize/deserialize Kotlin data classes correctly).

<build>
    <plugins>
        <!-- Must be declared before maven-compiler-plugin so Kotlin compiles first -->
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <version>2.4.0</version>
            <executions>
                <execution>
                    <id>compile</id>
                    <phase>compile</phase>
                    <goals><goal>compile</goal></goals>
                </execution>
                <execution>
                    <id>test-compile</id>
                    <phase>test-compile</phase>
                    <goals><goal>test-compile</goal></goals>
                </execution>
            </executions>
            <configuration>
                <sourceDirs>
                    <sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
                    <sourceDir>${project.basedir}/src/main/java</sourceDir>
                </sourceDirs>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                    <plugin>jpa</plugin>
                </compilerPlugins>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>2.4.0</version>
                </dependency>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-noarg</artifactId>
                    <version>2.4.0</version>
                </dependency>
            </dependencies>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
        </plugin>
    </plugins>
</build>

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
</dependencies>

kotlin-maven-plugin configuration details change between Kotlin releases; the official Kotlin Maven documentation is the current reference.

Gradle

Gradle needs no explicit ordering step equivalent to Maven’s: the Kotlin Gradle plugin wires compileKotlin to run before compileJava automatically whenever both source sets are present, since Gradle’s Kotlin and Java source sets (src/main/kotlin, src/main/java) compile via separate, dependency-ordered tasks. Applying org.jetbrains.kotlin.plugin.spring and org.jetbrains.kotlin.plugin.jpa alongside the standard Java/Spring Boot plugins covers the same kotlin-spring/kotlin-jpa all-open/no-arg behavior described above for Maven.

// build.gradle.kts
plugins {
    java
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.6"
    kotlin("jvm") version "2.4.0"
    kotlin("plugin.spring") version "2.4.0"   // "kotlin-spring": auto-opens @Configuration/@Service/etc.
    kotlin("plugin.jpa") version "2.4.0"       // "kotlin-jpa": auto-opens @Entity, adds a no-arg constructor
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}

async/await in Other Languages

Sequential-looking asynchronous code via native async/await syntax is not unique to the JVM world — most mainstream languages have their own version of it:

Choosing a Model

Project Reactor Virtual Threads Kotlin Coroutines

Execution model

Non-blocking, operator-composed Mono/Flux pipelines; assembly then subscription.

Blocking-style code on cheap, JVM-scheduled threads, one per request.

Sequential-looking suspend functions with structured concurrency.

Transaction management

ReactiveTransactionManager bound to the Reactor Context, via TransactionalOperator or @Transactional on a Publisher-returning method.

Classic ThreadLocal-bound PlatformTransactionManager, unchanged from pre-virtual-thread code.

ReactiveTransactionManager again, driven through TransactionalOperator.executeAndAwait on suspend functions.

Best fit

Genuinely streaming/backpressure-sensitive workloads, or a stack already built on WebFlux/R2DBC.

Existing blocking Java code and libraries that should scale without a rewrite.

A team already writing Kotlin that wants sequential-looking async code.

Key risk

Steep learning curve; one blocking call anywhere in the chain breaks the chain.

Pinning from synchronized blocks still costs a platform-thread carrier.

Losing the transaction/coroutine context across a suspension point.

There is no strictly "best" choice among the three. Virtual Threads is the lowest-migration-cost option for an existing blocking Java codebase; Reactor remains the right choice for genuinely streaming or backpressure-sensitive workloads, or a stack that is already reactive; Coroutines fits a team already writing Kotlin. See Java or Kotlin for Spring Boot? for the broader Java-vs-Kotlin decision beyond concurrency alone.