🔀 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: suspend functions and Flow, 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 if and try
  • 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> becomes suspend fun ...: T (or T? if it can be empty)
  • Mono<Void> becomes suspend fun ...()
  • Flux<T> becomes Flow<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=true if something like @Scheduled is 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 suspend functions may show as “was optimized out”; the -Xdebug compiler 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 the ReactorDebugAgent from reactor-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 ThreadLocal and MDC behave as they always did. Values don’t follow you into threads you start, though.
  • Reactor: the Reactor Context isn’t a ThreadLocal. Restoring ThreadLocal values such as MDC needs the io.micrometer:context-propagation library. Spring Boot’s spring.reactor.context-propagation property defaults to limited; setting it to auto turns on automatic propagation of observations, trace ids, and span ids.
  • Coroutines: a coroutine can resume on a different thread. For MDC, kotlinx-coroutines-slf4j provides MDCContext — but watch the trap in its docs: an MDC.put inside the coroutine is lost at the next suspension unless you capture it with withContext(MDCContext()). For Micrometer observations, Spring Framework 7 adds PropagationContextElement.

🔬 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:

  1. 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.
  2. Existing reactive stack (WebFlux, R2DBC, WebClient)? Keep Reactor underneath. In Kotlin, write controllers and services as suspend functions and Flow, and use awaitSingle and mono { } at the seams.
  3. 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.
  4. Fan-out to several services per request? Coroutines with coroutineScope and async. Java’s own StructuredTaskScope is still a preview API in JDK 26 (JEP 525), so I wouldn’t build on it yet.
  5. 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.fromCallable and calling it reactive
  • enabling virtual threads on Java 21 without checking for synchronized around 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


Related Posts