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:
-
ThreadLocalcleanup on cancellation/timeout. A virtual thread itself is not pooled, but whatever cleans up the transaction — the interceptor’sfinallyblock — must still run for theThreadLocalto be cleared. If a request is cancelled or times out in a way that skips normal completion, a leakedThreadLocalentry is still possible, same as on a platform thread. -
InheritableThreadLocalsemantics differ from pooled platform threads. A pooled worker thread is created once and inherits itsInheritableThreadLocalvalues 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 copyInheritableThreadLocalvalues 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
synchronizedblock 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:
-
JavaScript — Asynchronous JavaScript, with TypeScript’s own treatment in Async, Iterators and Generators.
-
Python — Concurrency and Async.
-
C# — no dedicated internal fundamentals page exists yet in this catalog (only scattered Task-based usage under the ASP.NET Core section, e.g. HTTP Client and Resilience); see Microsoft’s own C# async/await documentation in the meantime. This gap is a reasonable candidate for a future, dedicated page.
Choosing a Model
| Project Reactor | Virtual Threads | Kotlin Coroutines | |
|---|---|---|---|
Execution model |
Non-blocking, operator-composed |
Blocking-style code on cheap, JVM-scheduled threads, one per request. |
Sequential-looking |
Transaction management |
|
Classic |
|
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 |
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.
References
-
the Spring Framework reference — Coroutines, which documents
executeAndAwaitand reactive transaction support forsuspendfunctions. -
spring-projects/spring-framework#28290, the known coroutine-context-loss issue referenced above.