🔀 Three Answers to the Same Problem Link to heading
A few years ago, the question for a Kotlin team on Spring was simple: blocking Spring MVC, or reactive WebFlux with Project Reactor?
That’s no longer the whole menu. Now there are three credible ways to keep threads from sitting idle while you wait on I/O:
- Project Reactor:
Mono,Flux, and the WebFlux stack - Kotlin coroutines:
suspendfunctions andFlow, supported directly in Spring controllers - JVM virtual threads: plain blocking code, cheap threads underneath
I’ve already written about Reactor with Kotlin and a small nonblocking delayUntil pattern. This post puts Reactor next to the other two and asks the practical question — which one should your Kotlin service use?
Short version: it depends less on taste than on what your stack already looks like.
🧪 The Same Endpoint Three Ways Link to heading
Here’s one endpoint, “get an order by id”, three ways.
Reactor (WebFlux, reactive repository):
@RestController
class OrderController(private val orders: ReactiveOrderRepository) {
@GetMapping("/orders/{id}")
fun order(@PathVariable id: Long): Mono<Order> =
orders.findById(id)
.switchIfEmpty(Mono.error(ResponseStatusException(HttpStatus.NOT_FOUND)))
}
Coroutines (same reactive repository, bridged with awaitSingleOrNull):
@RestController
class OrderController(private val orders: ReactiveOrderRepository) {
@GetMapping("/orders/{id}")
suspend fun order(@PathVariable id: Long): Order =
orders.findById(id).awaitSingleOrNull()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
Blocking on virtual threads (Spring MVC, JPA repository):
@RestController
class OrderController(private val orders: OrderRepository) {
@GetMapping("/orders/{id}")
fun order(@PathVariable id: Long): Order =
orders.findByIdOrNull(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
The last two read almost the same — both let you write sequential code. They just get there in very different ways.
⚛️ Reactor: Still the Streaming Specialist Link to heading
Reactor models work as a pipeline of operators over Mono and Flux. Nothing blocks, and Reactive Streams backpressure is built in.
Where it shines:
- streaming data with real backpressure
- composing many async operations with a rich operator set (
zip,retryWhen,bufferTimeout, and so on) - an existing WebFlux, R2DBC, or reactive client stack
Where it hurts:
- every call in the chain has to stay reactive, or you’ll lose the benefit
- control flow lives in operators instead of in
ifandtry - stack traces show Reactor internals rather than where your chain was built
🧵 Coroutines: Sequential Code on a Reactive Stack Link to heading
Spring’s coroutine support maps the reactive types to Kotlin ones:
Mono<T>becomessuspend fun ...: T(orT?if it can be empty)Mono<Void>becomessuspend fun ...()Flux<T>becomesFlow<T>
Add kotlinx-coroutines-core and kotlinx-coroutines-reactor, and Spring supports suspend functions and Flow return values in both WebFlux and Spring MVC annotated controllers, plus coRouter { } for functional endpoints, CoWebFilter, and WebClient extensions.
The bridges in kotlinx-coroutines-reactor and kotlinx-coroutines-reactive are what make mixed codebases workable:
// Reactor -> coroutines
val order: Order = orderMono.awaitSingle() // throws if empty
val maybe: Order? = orderMono.awaitSingleOrNull()
val stream: Flow<Event> = eventFlux.asFlow()
// coroutines -> Reactor
val result: Mono<Dashboard> = mono { dashboardService.dashboard(userId) }
val events: Flux<Event> = eventFlow.asFlux()
The real win, though, is structured concurrency. Fan-out is where I reach for coroutines first:
@Service
class DashboardService(
private val profiles: ProfileClient,
private val orders: OrderClient,
private val recommendations: RecommendationClient,
) {
suspend fun dashboard(userId: Long): Dashboard = coroutineScope {
val profile = async { profiles.profile(userId) }
val recent = async { orders.recent(userId) }
val recs = async { recommendations.forUser(userId) }
Dashboard(profile.await(), recent.await(), recs.await())
}
}
If any call fails, the other two are cancelled and the exception propagates — no orphaned work, no manual cleanup. The Mono.zip version from the Reactor intro works too, but this reads like the business logic it is.
Flow covers streaming as well. Spring’s docs describe it as the coroutine equivalent of Flux, with backpressure implemented through suspension.
🪶 Virtual Threads: Keep the Blocking Code Link to heading
Virtual threads were finalized in Java 21 (JEP 444). They’re cheap, JVM-managed threads: when one blocks on I/O, it normally unmounts from its carrier thread so that carrier can run something else.
In Spring Boot 3.2 and later, turning them on is one property (more on the second line below):
spring.threads.virtual.enabled=true
spring.main.keep-alive=true
Tomcat and Jetty then handle requests on virtual threads, and the auto-configured task executor and scheduler use them too. A few details from the Spring Boot docs:
- thread-pool sizing properties stop having an effect, because virtual threads run on a JVM-wide scheduler
- virtual threads are daemon threads, so the docs recommend
spring.main.keep-alive=trueif something like@Scheduledis the only thing keeping the JVM alive - Java 21 is the minimum, but the docs “strongly recommend” Java 24 or later
That last point is all about pinning.
📌 Pinning, and What JDK 24 Changed Link to heading
Under JEP 444, a virtual thread that blocked inside a synchronized block or method stayed pinned to its carrier, tying up a real OS thread — undoing the benefit without any warning. The JEP’s advice was to replace long-lived synchronized sections that guard I/O with ReentrantLock.
JEP 491, delivered in JDK 24, fixed most of that: virtual threads can now hold monitors independently of their carrier, so blocking inside synchronized, or in Object.wait(), no longer pins. A few cases still do — native frames on the stack (including blocking inside a class initializer), and waiting for another thread to finish initializing a class.
My advice: for virtual threads in production, run Java 25, the current long-term support release. On Java 21, audit your synchronized blocks — and your libraries’ — first.
🚫 What Virtual Threads Don’t Do Link to heading
JEP 444 is explicit: virtual threads are about throughput, not latency. They don’t make code faster — they let more requests wait at once.
They don’t create database connections either, so if your service is bound by a connection pool, the pool’s still the limit. And since the JEP says virtual threads should never be pooled, cap concurrency against a downstream system with something like a Semaphore.
💸 The Costs That Don’t Make the Slide Link to heading
🐞 Debugging Link to heading
- Virtual threads: ordinary stack traces, ordinary debugger. The easiest of the three.
- Coroutines: IntelliJ has a Coroutines tab for created, running, and suspended coroutines. Variables in
suspendfunctions may show as “was optimized out”; the-Xdebugcompiler flag avoids that, but Kotlin’s docs warn never to use it in production. - Reactor: the hardest. The reference docs give you
checkpoint()on a single chain,Hooks.onOperatorDebug()globally (expensive — a last resort), and theReactorDebugAgentfromreactor-tools. They help, and they’re a sign of the cost.
🔗 Context Propagation (MDC, Trace IDs) Link to heading
This is where teams get burned, because it fails silently — your logs just lose their trace id.
- Virtual threads: each request runs on its own thread, so
ThreadLocaland MDC behave as they always did. Values don’t follow you into threads you start, though. - Reactor: the Reactor
Contextisn’t aThreadLocal. RestoringThreadLocalvalues such as MDC needs theio.micrometer:context-propagationlibrary. Spring Boot’sspring.reactor.context-propagationproperty defaults tolimited; setting it toautoturns on automatic propagation of observations, trace ids, and span ids. - Coroutines: a coroutine can resume on a different thread. For MDC,
kotlinx-coroutines-slf4jprovidesMDCContext— but watch the trap in its docs: anMDC.putinside the coroutine is lost at the next suspension unless you capture it withwithContext(MDCContext()). For Micrometer observations, Spring Framework 7 addsPropagationContextElement.
🔬 Testing Link to heading
All three test well: StepVerifier from reactor-test (with withVirtualTime for time-based operators), runTest from kotlinx-coroutines-test (which skips delays using virtual time), and — for virtual threads — plain JUnit tests of plain blocking code.
🧭 A Practical Decision Guide Link to heading
Here’s where I’d start:
- Blocking stack (Spring MVC, JDBC/JPA, blocking clients)? Turn on virtual threads, on Java 25. Don’t rewrite to reactive to get throughput you can now get with one property.
- Existing reactive stack (WebFlux, R2DBC,
WebClient)? Keep Reactor underneath. In Kotlin, write controllers and services assuspendfunctions andFlow, and useawaitSingleandmono { }at the seams. - Real streaming with backpressure (event streams, large result sets, server-sent events)? Reactor or
Flow. Virtual threads give you cheap waiting — they don’t give you a backpressure protocol. - Fan-out to several services per request? Coroutines with
coroutineScopeandasync. Java’s ownStructuredTaskScopeis still a preview API in JDK 26 (JEP 525), so I wouldn’t build on it yet. - New service, no constraints? Spring MVC plus virtual threads is the simplest thing that scales for most request/response APIs. Add coroutines where you need fan-out.
And what I’d steer clear of:
- mixing all three in one service without a reason
- calling
block()inside WebFlux handlers - wrapping blocking JDBC in
Mono.fromCallableand calling it reactive - enabling virtual threads on Java 21 without checking for
synchronizedaround I/O
✅ My Take Link to heading
Reactor isn’t obsolete. It’s still the best tool for streaming and backpressure, and a well-built WebFlux service doesn’t need a rewrite.
But for the common case — a request comes in, you call a database and two other services, you return JSON — virtual threads changed the math. The blocking model is cheap again, and it’s the easiest to debug, trace, and hire for.
For Kotlin teams, coroutines sit in a useful middle spot. They make a reactive stack readable, and their structured concurrency is the cleanest fan-out tool I know of on the JVM today.
So my default for a new Kotlin service on Spring is MVC, virtual threads, Java 25, and coroutines only where fan-out earns them. It won’t win any architecture-diagram contests, but it’s the setup I’d most want to be on call for — and if reactive always felt like too much work, you may get to skip it entirely.
Sources
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 525: Structured Concurrency (Sixth Preview)
- JDK 25 project page
- Spring Boot reference: Virtual threads
- Spring Boot 3.2 Release Notes
- Spring Boot application properties
- Spring Framework reference: Coroutines
- Spring Framework 7.0 Release Notes
- kotlinx-coroutines-reactor API
- kotlinx-coroutines-reactive: asFlow
- Kotlin docs: Composing suspending functions
- Kotlin docs: Debug coroutines using IntelliJ IDEA
- kotlinx-coroutines-slf4j: MDCContext
- kotlinx-coroutines-test
- Reactor reference: Debugging
- Reactor reference: Context-Propagation Support
- Reactor reference: Testing
Related Posts