Performance, leaks, and the mistakes that only show up under load
suspend does not mean concurrent, GlobalScope means unowned, and one blocking call on the event loop undoes the entire reactive stack.
Explore the full explanation 8 ideas
The first and most expensive misconception is that `suspend` means concurrent. It does not: a suspending function runs sequentially, exactly like a normal one, and `listOf(a, b, c).map { fetch(it) }` makes three calls one after another. Concurrency is opt-in and always visible — `async` + `awaitAll`, `launch` inside a `coroutineScope`, or `flatMapMerge` on a Flow. When a handler that should take 100 ms takes 300, the first thing to check is whether three independent awaits were written as three sequential statements.
The second is that a coroutine is not free. It is far cheaper than a thread — a suspended coroutine is a small object on the heap, so a hundred thousand of them are unremarkable where a hundred thousand threads are impossible — but each one still carries a `Job`, a context, and continuation objects. Launching one per element of a large collection to do trivial work is slower than a plain loop; the win comes from *waiting* cheaply, not from computing cheaply. Coroutines make I/O-bound work scale; they do nothing for CPU-bound work except let you distribute it explicitly.
The third, and the one that takes production down, is blocking the wrong dispatcher. `Dispatchers.Default` is sized to your CPU count, and Netty's event loop is roughly one thread per core: a single `Thread.sleep`, JDBC call, `RestTemplate` request or `File.readText` on one of those threads removes a meaningful fraction of your entire capacity for its duration. Under load it is not slow, it is a stall. The correct escape hatch is `withContext(Dispatchers.IO) { … }` — a large elastic pool built for exactly this — and the correct destination is a non-blocking driver so you do not need the escape hatch at all.
`Dispatchers.IO` is not unlimited, and treating it as a dumping ground is its own failure mode. It defaults to 64 threads (or the CPU count if larger), and one subsystem that saturates it starves every other blocking call in the process. `Dispatchers.IO.limitedParallelism(n)` carves a capped view for a subsystem — a legacy client, a report generator, an export job — so a slow dependency cannot consume the whole pool. That is a two-word change with the blast radius of a bulkhead.
Leaks in coroutine code are almost always one of two shapes. The first is `GlobalScope`: it has no `Job` that anyone owns, so nothing ever cancels its children, and a coroutine launched there outlives the request, the screen, or the object that started it. The second is a scope you created and never cancelled — a `CoroutineScope(...)` field on a component with no `close()`. Both are found the same way: every `launch` should be traceable to a scope whose lifetime you can name out loud, and if you cannot name it, it is a leak.
`runBlocking` deserves its own warning. It bridges blocking code to suspending code by parking the calling thread until the body completes — which is correct in a `main` function or a test, and a bug anywhere inside a server request. On an event-loop thread it blocks the loop; called from inside another coroutine it blocks a pooled thread and can deadlock outright if the body needs that same limited dispatcher to make progress. If you find `runBlocking` in a handler, the fix is not to tune it, it is to make the caller `suspend`.
Two smaller costs are worth knowing. **Unbounded fan-out**: `list.map { async { call(it) } }.awaitAll()` on a ten-thousand element list issues ten thousand simultaneous requests, which is a denial of service you wrote yourself — bound it with `Semaphore(k)` or `flatMapMerge(concurrency = k)`. And **unbounded buffering**: `Channel.UNLIMITED`, `buffer(Int.MAX_VALUE)` or an ever-growing `MutableSharedFlow` replay turn a producer/consumer mismatch from a slowdown into an out-of-memory error, which is strictly worse because back-pressure would have told you something was wrong.
Measure before you tune. `runTest`'s virtual clock turns "is this pipeline sequential?" into an exact number, so the sequential-versus-buffered question is answered in a test rather than in production. Add `-Dkotlinx.coroutines.debug` to get coroutine names in thread dumps, and name your coroutines so those dumps mean something. And when a handler is slow, check the three usual suspects in order: sequential awaits that should be concurrent, a blocking call on a small dispatcher, and an unbounded fan-out hitting a rate limit — in practice it is nearly always one of those three.
Let’s figure it out.
Ask me about this course or where to start.
import kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*suspend fun fetch(id: Int): String { delay(100); return "r" + id }@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {var t = currentTimeval sequential = (1..3).map { fetch(it) } // one after anotherprintln("sequential " + sequential + " in " + (currentTime - t) + "ms")t = currentTimeval concurrent = coroutineScope { // opt in, explicitly(1..3).map { id -> async { fetch(id) } }.awaitAll()}println("concurrent " + concurrent + " in " + (currentTime - t) + "ms")t = currentTimeval streamed = (1..3).asFlow().flatMapMerge(concurrency = 3) { id -> flow { emit(fetch(id)) } }.toList()println("flatMapMerge " + streamed.sorted() + " in " + (currentTime - t) + "ms")}
suspend is not concurrent. The same three calls, written three ways, with the virtual clock reporting the difference exactly.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.sync.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {suspend fun run(limit: Int?): String {val gate = limit?.let { Semaphore(it) }var inFlight = 0; var peak = 0val start = currentTimecoroutineScope {repeat(12) {launch {suspend fun body() {inFlight++; if (inFlight > peak) peak = inFlightdelay(100)inFlight--}if (gate == null) body() else gate.withPermit { body() }}}}return "peak=" + peak + " elapsed=" + (currentTime - start)}println("unbounded -> " + run(null)) // 12 simultaneous callsprintln("Semaphore(4) -> " + run(4)) // four at a time, three waves}
Unbounded fan-out is a self-inflicted denial of service. Semaphore(k) turns it into a load you chose, and the peak counter proves the cap held.
Arena IDEimport kotlinx.coroutines.*@OptIn(DelicateCoroutinesApi::class)fun main() = runBlocking {var owned = 0var leaked = 0val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)// Owned: cancelling the scope stops it.scope.launch { repeat(10) { delay(20); owned++ } }// Leaked: GlobalScope has no Job anyone owns, so nothing cancels it.GlobalScope.launch { repeat(10) { delay(20); leaked++ } }delay(50)scope.cancel() // "shut down the component"val ownedAtShutdown = owneddelay(150)println("owned stopped at " + ownedAtShutdown + ", still " + owned + " after 150ms")println("leaked kept going to " + leaked + " — nothing could stop it")}
GlobalScope has no owner, so cancelling the scope you DO own leaves it running. Every launch should trace back to a lifetime you can name.
Arena IDE@Serviceclass ReportService(private val jdbc: JdbcTemplate,// INJECT the dispatcher: a test can substitute StandardTestDispatcher and// get virtual time. A hard-coded Dispatchers.IO cannot be tested that way.private val io: CoroutineDispatcher = Dispatchers.IO,) {// A bulkhead: this legacy client gets 8 threads, not all 64, so a slow// provider cannot starve every other blocking call in the process.private val legacy = Dispatchers.IO.limitedParallelism(8)// WRONG on WebFlux: JDBC pins a Netty event-loop thread for the whole query.suspend fun badReport(id: Long): Report =buildReport(jdbc.queryForList("SELECT * FROM big WHERE id = ?", id))// Right: the blocking call runs on a pool sized for waiting.suspend fun report(id: Long): Report = withContext(io) {buildReport(jdbc.queryForList("SELECT * FROM big WHERE id = ?", id))}suspend fun legacyLookup(id: Long): Quote = withContext(legacy) {legacyClient.blockingQuote(id)}}
The escape hatch and the bulkhead: offload blocking work to IO, and cap a noisy subsystem so it cannot consume the whole pool. Reference only (needs Spring).
🧠 Check your understanding
0/1 · 0/1 answered1. A WebFlux handler awaits three independent downstream services and takes ~300 ms when each service takes ~100 ms. The code is fully `suspend`, with no blocking calls. What is the most likely cause?