The suspend + WebFlux mental model
In WebFlux, suspend doesn't make your handler faster โ it lets one thread serve thousands of requests by parking work instead of blocking.
Classic Spring MVC follows a thread-per-request model: every incoming HTTP request grabs a dedicated thread from a pool, and that thread stays pinned to the request until the response is fully written. When the handler calls a database or another HTTP service, the thread sits idle โ blocked โ waiting for bytes to arrive. With a few hundred concurrent slow requests, the pool is exhausted and new requests queue up even though the CPU is mostly doing nothing. Spring WebFlux exists to break that coupling between 'a request in flight' and 'a thread held hostage'.
WebFlux runs on a small, fixed-size pool of event-loop threads (Netty's by default โ typically one per CPU core). The golden rule of the event loop is simple and absolute: never block it. A handler must hand control back to the loop the instant it would otherwise wait on I/O, so that same thread can immediately pick up work for another request. Underneath, WebFlux is built on Project Reactor's Mono and Flux, which model a computation as a non-blocking pipeline that emits its result later via a callback rather than by parking the calling thread.
Reactor pipelines are powerful but read awkwardly โ chains of flatMap, zipWith, and switchIfEmpty obscure ordinary control flow. Kotlin coroutines solve this. A suspend function can pause at a suspension point and resume later without holding its thread: when a coroutine suspends, the underlying event-loop thread is released back to serve other work, and the coroutine is resumed (possibly on a different thread) once its awaited result is ready. This gives you the non-blocking behavior WebFlux demands, but written as plain sequential, top-to-bottom code with normal try/catch, loops, and if-statements.
Spring bridges the two worlds directly. A controller method can simply be declared suspend, and Spring will subscribe to the resulting coroutine, translate suspension into Reactor's reactive contract, and write the response when the coroutine completes โ no manual Mono.fromCallable or .block() anywhere. Returning a Flow<T> maps to a streaming Flux<T> response. The framework adapts your idiomatic coroutine code onto the reactive runtime for you, so the handler looks synchronous while behaving asynchronously.
This is why every method in the call chain should be suspend, all the way down. A suspending handler that calls a regular blocking function gains nothing: the moment the blocking call runs (say, a JDBC query or a Thread.sleep), it freezes the event-loop thread, and you have reintroduced exactly the bottleneck WebFlux was designed to remove โ except now with a far smaller pool, making the damage worse. Suspension only propagates if the whole path cooperates: suspend controller, suspend service, suspend repository, and a non-blocking driver (R2DBC for SQL, reactive Mongo, a coroutine-aware HTTP client) at the very bottom.
The crucial distinction is non-blocking versus blocking I/O, not async versus sync syntax. Non-blocking I/O (R2DBC, Netty's client, reactive drivers) registers interest in a socket and returns immediately; the OS notifies the event loop when data is ready, and no thread waits in between. Blocking I/O (JDBC, java.io streams, RestTemplate, file reads) occupies its thread for the entire duration of the wait. Wrapping a blocking call in a coroutine does not make it non-blocking โ it just moves the blocking somewhere else.
When you genuinely must call legacy blocking code from a WebFlux app, the correct escape hatch is to offload it to a dedicated thread pool with withContext(Dispatchers.IO) (or a bounded custom dispatcher). That keeps the event-loop threads free while the blocking work occupies an IO thread you can size and isolate. It is a pragmatic bridge, not the goal โ the destination is a fully non-blocking stack where suspension, not thread-parking, carries the waiting.
Hold onto the mental picture: a suspend handler on WebFlux is a state machine that voluntarily yields its thread at every await, lets that thread serve other requests while a network call is in flight, and resumes only when its data arrives. Throughput stops scaling with the number of threads and starts scaling with available memory and connections. That is the entire payoff โ and it only holds if you keep the path non-blocking from the controller down to the driver.
@RestControllerclass OrderController(private val service: OrderService) {// suspend handler: Spring adapts it onto Reactor's reactive contract@GetMapping("/orders/{id}")suspend fun getOrder(@PathVariable id: Long): OrderDto =service.findOrder(id)// returning a Flow maps to a streaming Flux<T> response@GetMapping("/orders")fun streamOrders(): Flow<OrderDto> =service.allOrders()}@Serviceclass OrderService(private val repo: OrderRepository) {// every layer is suspend so suspension propagates all the way downsuspend fun findOrder(id: Long): OrderDto =repo.findById(id)?.toDto() ?: throw OrderNotFound(id)fun allOrders(): Flow<OrderDto> =repo.findAll().map { it.toDto() }}// Coroutine-aware R2DBC repository: suspend + Flow, non-blocking driverinterface OrderRepository : CoroutineCrudRepository<Order, Long>
A fully suspending WebFlux stack. Spring subscribes to the suspend controller automatically; suspension flows from controller to service to a non-blocking R2DBC repository. No .block(), no Mono plumbing โ yet nothing pins the event-loop thread.
@GetMapping("/report/{id}")suspend fun badReport(@PathVariable id: Long): Report {// WRONG: JDBC is blocking I/O. It does NOT suspend.// The event-loop thread is pinned until the query returns.val rows = jdbcTemplate.queryForList("SELECT * FROM big_table WHERE id = ?", id)Thread.sleep(200) // also blocks the loop โ never do this in WebFluxreturn buildReport(rows)}// Correct escape hatch when you MUST use legacy blocking code:@GetMapping("/report/{id}")suspend fun goodReport(@PathVariable id: Long): Report = withContext(Dispatchers.IO) {// blocking work now runs on a dedicated IO pool; event-loop stays freeval rows = jdbcTemplate.queryForList("SELECT * FROM big_table WHERE id = ?", id)buildReport(rows)}
The trap. This handler LOOKS async because it is suspend, but the blocking JDBC/sleep call freezes a precious event-loop thread for the whole wait. Under load the tiny Netty pool starves and latency collapses.
import kotlinx.coroutines.*import kotlin.system.measureTimeMillissuspend fun handleRequest(id: Int): Int {delay(1000) // suspends, like awaiting non-blocking I/O โ no thread heldreturn id * 2}fun main() = runBlocking {val elapsed = measureTimeMillis {val results = (1..10_000).map { id ->async { handleRequest(id) } // 10k concurrent coroutines}.awaitAll()println("Handled ${results.size} requests")}// ~1000ms total, not 10,000 seconds: suspension, not threads, carries the waitprintln("Took ${elapsed}ms on a tiny thread pool")}
Why suspension scales while blocking does not โ runnable on plain kotlinx.coroutines. delay() suspends (frees the thread), so 10,000 'requests' finish in ~1s on a handful of threads. Swap delay for Thread.sleep and you would need 10,000 threads to match it.
Arena IDE๐ง Check your understanding
0/1 ยท 0/1 answered1. You add a suspend keyword to a WebFlux controller method, but inside it you call a JDBC repository (blocking) without any dispatcher change. What actually happens under load?