Suspend All the Way Down
One blocking call hiding inside a suspend function chain is enough to stall every other request sharing its thread — here's how to make sure it never happens.
VehicleValuationService.valuate is a suspend function, and that choice ripples upward and downward through the whole call chain. Spring's web layer — both MVC and WebFlux — has first-class support for Kotlin coroutines: a controller handler can itself be declared suspend fun getValuation(vin: String), and Spring will run it inside a coroutine on the caller's behalf, without you touching a Mono, a Flux, or a CompletableFuture anywhere. This is what lets a codebase read like straight-line, sequential code — await this, then await that — while still behaving asynchronously under the hood.
The reason to reach for coroutines here is concrete: VehicleValuationService.valuate does I/O-bound work in sequence and in parallel — checking Redis, calling out to three separate HTTP providers, reading and writing Postgres — and none of that work should tie up a thread while it waits on a network response. Threads are a limited, relatively expensive resource; a typical servlet thread pool might have a few hundred threads, and if each one blocks for the 200-800ms a provider call can take, your whole service's throughput is capped by how many requests can be in flight at once, not by how much CPU work is actually happening. Suspending, rather than blocking, means the thread is released back to the pool while a coroutine awaits, and can go serve a different request in the meantime.
The danger is that suspend does not automatically mean non-blocking. If you call a genuinely blocking API from inside a suspend function — a classic JDBC call through the traditional blocking Postgres driver, for instance, or a synchronous OkHttp call made without wrapping it — you have not made that operation asynchronous, you have just put a blocking call inside a coroutine and called it a day. The thread running that coroutine still blocks, exactly as it would in non-coroutine code; you've only added the illusion of asynchrony. Worse, coroutines commonly run on a shared, size-limited dispatcher (Dispatchers.Default has as many threads as CPU cores, by design, for CPU-bound work), so a blocking call placed there can starve unrelated coroutines that have nothing to do with your slow database query.
The fix is withContext(Dispatchers.IO): a dispatcher backed by a much larger, elastic thread pool specifically intended to absorb blocking calls that you cannot avoid making. Wrapping a blocking repository call as withContext(Dispatchers.IO) { blockingRepository.findByVin(vin) } moves that specific call onto a thread meant to tolerate blocking, and suspends the calling coroutine (freeing its original thread) until the blocking call returns. This is the correct pattern precisely when you're stuck with a blocking dependency — a JDBC driver, a synchronous SDK from a vendor, a legacy client — that has no coroutine-native equivalent.
But withContext(Dispatchers.IO) is a patch, not the ideal — the better outcome, wherever you control the dependency, is a client that suspends natively rather than one you wrap. This is exactly why BlackBookClient, CarfaxClient, and VisClient are written against an HTTP client with real coroutine support (a later module in this course covers this in depth) rather than a blocking HTTP library: a native suspend fun quote(vin: String): Quote genuinely yields the thread while awaiting the network response, with no thread pinned and waiting for the duration of the call. The same applies to VehicleRepository — Spring Data JPA's repository methods can be declared suspend, and R2DBC-based reactive repositories are non-blocking natively, whereas the traditional JDBC-backed JPA stack is fundamentally blocking underneath, no matter what keyword you put in front of the Kotlin function signature.
The practical rule for this codebase, then, is to keep the whole call chain suspending, top to bottom: the controller handler is suspend, valuate is suspend, each provider client's quote method is suspend and genuinely non-blocking, and any unavoidable blocking call — a legacy synchronous integration, say — gets explicitly and narrowly wrapped in withContext(Dispatchers.IO) at the exact point it happens, never assumed to be safe just because it's nested inside other suspend functions. A chain is only as non-blocking as its least disciplined link.
One more habit worth building early: never call a suspend function from a non-coroutine context by reaching for runBlocking as a shortcut inside request-handling code. runBlocking does exactly what its name says — it blocks the current thread until the coroutine completes — which reintroduces the very problem suspend functions exist to avoid. It has legitimate uses (a main function, a test), but inside a controller or service that Spring is already running as a coroutine, it's a sign that something in the design has gone sideways, not a tool to reach for casually.
@RestControllerclass VehicleController(private val valuationService: VehicleValuationService) {@GetMapping("/vehicles/{vin}/valuation")suspend fun getValuation(@PathVariable vin: String): ValuationResponse {return valuationService.valuate(vin).toResponse()}}@Serviceclass VehicleValuationService(private val blackBook: BlackBookClient,) {suspend fun valuate(vin: String): Valuation {val quote = blackBook.quote(vin) // suspends, never blocks the threadreturn Valuation.from(quote)}}interface BlackBookClient {suspend fun quote(vin: String): Quote // backed by a coroutine-native HTTP call}
The suspend chain from handler to client, as it should look: nothing here blocks a shared thread. Requires Spring's coroutine support and real network/database clients, so it does not run in-browser.
@Serviceclass VehicleValuationServiceBad(private val blockingRepository: VehicleRepository, // traditional JDBC-backed JPA) {suspend fun valuate(vin: String): Valuation {// DANGER: findByVin blocks the calling thread on a JDBC round trip.// Because this method is `suspend`, it looks safe -- it is not.val rules = blockingRepository.findByVinBlocking(vin)return Valuation.fromRules(rules)}}
The anti-pattern: a blocking JDBC-backed call made directly inside a suspend function, silently pinning a shared-pool thread for its full duration. Requires a real blocking JPA repository, so it does not run in-browser.
import kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.asyncimport kotlinx.coroutines.awaitAllimport kotlinx.coroutines.runBlockingimport kotlinx.coroutines.withContext// Stands in for a blocking JDBC call: it really does block the thread it runs on.fun blockingDatabaseCall(id: Int): String {Thread.sleep(200)return "row-$id"}suspend fun fetchRowBadly(id: Int): String {// No withContext: this blocks whatever thread the coroutine happens to be on.return blockingDatabaseCall(id)}suspend fun fetchRowProperly(id: Int): String {// Moves the blocking work to a dispatcher meant to absorb it.return withContext(Dispatchers.IO) {blockingDatabaseCall(id)}}fun main() = runBlocking {val start = System.currentTimeMillis()// Launch several "requests" concurrently, each fetching a row properly.val results = (1..5).map { id ->async { fetchRowProperly(id) }}.awaitAll()val elapsed = System.currentTimeMillis() - startprintln("Fetched: $results")println("Elapsed: ${elapsed}ms (concurrent, not 5x200ms=1000ms serial)")}
A pure-Kotlin, self-contained demonstration of why withContext(Dispatchers.IO) matters: it moves a blocking stand-in off the limited default dispatcher so other coroutines keep making progress. Uses only the Kotlin stdlib and kotlinx.coroutines, so it runs standalone.
Arena IDE🧠 Check your understanding
0/1 · 0/1 answered1. A code review flags this method: `suspend fun findByVin(vin: String): PricingRule = jdbcTemplate.queryForObject(...)`, where jdbcTemplate is Spring's traditional, JDBC-backed JdbcTemplate. What is the actual problem, and what is the correct fix?