Skip to content
article

Kotlin coroutines in a Spring service: three leaks we shipped and what structured concurrency fixed

GlobalScope, runBlocking in a request thread, and blocking JDBC on the wrong dispatcher

Bram Verhoeven@bram_on_a_bike

September 4, 2026 · 3 min read

We run our routing service in Kotlin on Spring Boot. Coroutines were introduced two years ago by enthusiastic people, including me. Here are the three mistakes we made, all of them explained in the kotlinx.coroutines docs, which none of us had read properly. Hence the topic.

1. GlobalScope for fire-and-forget

fun ship(order: Order) {
    GlobalScope.launch { auditClient.record(order.id, "shipped") }
    repository.markShipped(order.id)
}

It looks harmless. GlobalScope coroutines are not tied to anything. Nobody cancels them on shutdown, nobody sees their exceptions unless you install a handler, and nothing limits how many exist. One afternoon the audit database was slow and we had 50,000 coroutines waiting in memory. Then the pod restarted and we lost all of those audit records.

Fix: a scope with a lifecycle, owned by the application:

@Component
class AuditScope : DisposableBean {
    val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("audit"))

    override fun destroy() = scope.cancel()
}

Honestly, for audit records the real fix was even more boring: write them to an outbox table in the same transaction and let a worker ship them. No coroutine needed. But if you do need background work, give it a scope that someone owns.

2. runBlocking inside a request

Someone needed to call a suspend function from a normal Spring MVC controller, so they wrapped it in runBlocking. That blocks the Tomcat request thread until the coroutine finishes, which defeats the purpose, and nested runBlocking calls on a limited dispatcher can deadlock. Spring MVC has supported suspend fun controller methods for a long time now. Make the controller suspend and delete the runBlocking.

3. Blocking JDBC on Dispatchers.Default

Dispatchers.Default has as many threads as you have CPU cores. It is for CPU work. We were doing JDBC calls on it. Four slow queries on a four core pod and every other coroutine in the service waited. Blocking calls go to Dispatchers.IO, and we limit it to the size of the Hikari pool, so we do not create 64 threads that all wait for 20 connections:

private val db = Dispatchers.IO.limitedParallelism(20) // = hikari maximumPoolSize

suspend fun findRoute(id: Long): Route = withContext(db) {
    jdbcTemplate.queryForObject("SELECT * FROM routes WHERE id = ?", routeMapper, id)!!
}

What structured concurrency gives you

suspend fun quote(orderId: Long): Quote = withTimeout(800) {
    coroutineScope {
        val rates = async { carrierClient.rates(orderId) }
        val customs = async { customsClient.duties(orderId) }
        Quote(rates.await(), customs.await())
    }
}

If the customs call throws, the rates call is cancelled automatically. If the whole thing takes longer than 800 ms, both are cancelled. Nothing is left running in the background. Use supervisorScope instead when one failure should not cancel the siblings.

The one that surprises everyone

Cancellation is cooperative. withTimeout cancels the coroutine, but a blocking JDBC call inside it does not know that. The coroutine stops waiting, the database keeps working, and the thread stays busy until the query returns. We had a "2 second timeout" endpoint with queries running for 40 seconds. Set the timeout on the database side as well: a query timeout on the statement, or statement_timeout in Postgres. Both timeouts, or neither.

All of this is in the docs. I read them on the train last week because it rained too hard even for the bike. Do not tell anyone.

Written by

Bram Verhoeven

Staff engineer at a logistics scale-up in Utrecht. Distributed systems, Kotlin, Postgres. 14 km by bike each way, rain or not. Boring technology is a feature.

Get the best of #read-the-docs weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

9

Sign in to join the conversation.

Sign in to comment
Artyom Sokolov@relocant_artyom#4 in Read The Docs
GlobalScope in production is, how it is called, a time bomb with nice syntax. We had exactly case 3 on Android, blocking Room queries on the Default dispatcher, UI janked on every screen. Good writeup )))
2 net votes
Caio Nascimento@caio_backend#4 in Read The Docs
Mano, the runBlocking in the controller. I am working with Kotlin since 2018 and I found this in code review last month, from a senior. kkkkk
3 net votes
Tomasz Kowalski@tomasz_k#4 in Read The Docs
Good post. Point about cooperative cancellation is most important and nobody knows it. I saw team very surprised that 'timeout 2 seconds' request took 40 seconds. Set statement_timeout on database side, always.
1 net votes
Bram Verhoeven@bram_on_a_bike#1 in Read The Docs
Yes. The coroutine gives up waiting, the database keeps working. Both timeouts or neither.
0 net votes
Yoni Peretz@yoni_haifa#4 in Read The Docs
We went the other way. Removed coroutines from our Spring service and use virtual threads on Java 21. Less magic, same throughput for our IO-bound load. So what's the problem with that? Genuinely asking.
3 net votes
Bram Verhoeven@bram_on_a_bike#1 in Read The Docs
Nothing wrong with it. For plain blocking JDBC code, virtual threads are the simpler answer. Coroutines win when you want structured concurrency: parallel calls, timeouts, siblings cancelled together. Java's StructuredTaskScope does that too, but it was still a preview API last time I checked.
0 net votes
Dita Anggraini@dita_kotlin#4 in Read The Docs
The limitedParallelism tip is good. On Android we do the same for the database dispatcher. Measured: jank frames from 14% to 2% on a Rp1,5 juta phone after moving reads off Default. Measure it, ya.
4 net votes
Lucía Ferrer@lucia_ferrer_dev#4 in Read The Docs
Bram I don't know Kotlin but I read all because is you 😅 'the coroutine gives up waiting, the database keeps working', is same like my fetch with AbortController? the server still works?
3 net votes
Bram Verhoeven@bram_on_a_bike#1 in Read The Docs
Exactly the same. Aborting the fetch stops the browser waiting. The server keeps going unless it checks for the closed connection. Good connection to make.
3 net votes