Structured Concurrency in Suspend Handlers
Fan out parallel calls with coroutineScope, survive partial failures with supervisorScope, and let client disconnects cancel everything for free.
A typical Spring WebFlux suspend handler rarely needs just one thing. To render a dashboard you might need a user profile, their recent orders, and a billing balance, each living behind a different service or repository. The naive approach awaits them one after another, paying the sum of all latencies. Structured concurrency lets you launch those calls concurrently while guaranteeing that none of them outlives the request that started them. The handler suspends until all the work it spawned is finished, failed, or cancelled, with no leaked coroutines and no dangling background jobs.
The workhorse for the happy path is coroutineScope { }. It creates a new scope whose lifetime is bound to the block: you launch each independent call with async { }, then collect the results with await() or awaitAll(). Because all the async coroutines run concurrently, the total time is roughly the slowest call rather than the sum. coroutineScope only returns once every child has completed, which means the Dashboard you build inside the block is always fully populated by the time you return it. There is no manual join, no thread pool to manage, and no way to accidentally forget a pending future.
The defining trait of coroutineScope is its all-or-nothing failure model. If any child throws, the scope immediately cancels all the other still-running siblings and rethrows that exception out of the block. This is exactly what you want for an aggregate that is meaningless when incomplete: if the billing service is down, there is no point holding a half-built dashboard, so failing fast and surfacing one clean error is the correct behavior. The cancellation of siblings also stops wasted work, freeing connections and threads instead of letting doomed calls run to completion.
When partial results are acceptable, reach for supervisorScope { } instead. It has the same structured lifetime, but failures of one child no longer cancel the others; each async fails in isolation and stores its exception until you await it. The pattern is to await each Deferred inside its own try/catch and substitute a fallback or null on error. A news widget, a weather panel, and a stock ticker can render independently: if weather is down, the page still shows news and stocks rather than collapsing entirely. Choose supervisorScope deliberately, only where degraded output genuinely beats no output.
Cancellation propagation is where structured concurrency truly pays off in a reactive server. In WebFlux the suspend handler runs as a coroutine tied to the HTTP subscription. If the client closes the connection or navigates away, the framework cancels that coroutine, and structured concurrency cascades the cancellation to every child you launched with coroutineScope or supervisorScope. Your parallel downstream calls stop instead of running to completion against a client that will never read the response, which protects your thread pools, database connections, and upstream services from pointless load during traffic spikes.
That cancellation is cooperative, not preemptive, so your code must reach a suspension point for it to take effect. Every suspend function from the coroutines library, delay, and the suspending clients from Spring's WebClient and the reactive drivers check for cancellation and throw CancellationException at their suspension points. Tight CPU loops or blocking JDBC calls will not notice cancellation on their own; if you have such sections, call ensureActive() periodically or move blocking work to a dispatcher and wrap it so it can be interrupted. Add withTimeout(...) around fan-out blocks to cap total latency, since a timeout cancels the scope using the very same machinery.
A few rules keep this safe in production. Never use GlobalScope or a hand-rolled detached scope to start request work, because that severs the lifetime link and reintroduces the leaks structured concurrency exists to prevent. If you genuinely need fire-and-forget work that should outlive the response, launch it from an application-scoped CoroutineScope you own, with its own SupervisorJob and exception handler, never from the request coroutine. And remember that CancellationException is special: do not swallow it in a broad catch (Exception), or you will break cancellation propagation; rethrow it, or catch the specific exceptions you actually intend to recover from.
Put together, the mental model is simple. Use coroutineScope to fan out parallel calls when you need all of them and want fail-fast semantics. Use supervisorScope when independent pieces should degrade gracefully. Lean on the runtime to cancel everything when the client disconnects, and make sure your code yields at suspension points so that cancellation is honored. With these three tools, a handler that calls five services stays fast, resilient, and free of resource leaks, all while reading like ordinary sequential code.
@RestControllerclass DashboardController(private val users: UserClient,private val orders: OrderClient,private val billing: BillingClient,) {@GetMapping("/dashboard/{id}")suspend fun dashboard(@PathVariable id: String): Dashboard = coroutineScope {val profile = async { users.fetch(id) }val recentOrders = async { orders.recent(id) }val balance = async { billing.balance(id) }// async starts the three calls concurrently; await() only collects the// results, so total time ~ slowest call rather than the sum.// If billing.balance throws, profile and recentOrders are cancelled// and the exception propagates out of dashboard().Dashboard(profile = profile.await(),orders = recentOrders.await(),balance = balance.await(),)}}
Fan out three independent calls with coroutineScope + async. They run concurrently, the handler waits for all of them, and if any one fails the others are cancelled and the error surfaces. (Spring WebFlux suspend handler โ copy into your project; not runnable here.)
import kotlinx.coroutines.*suspend fun loadWidgets(): List<String> = supervisorScope {val news = async { "news: 3 new articles" }val weather = async<String> { throw RuntimeException("weather service down") }val stocks = async { "stocks: ACME +2.1%" }listOf(news, weather, stocks).map { widget ->try {widget.await()} catch (e: CancellationException) {throw e // never swallow cancellation} catch (e: Exception) {"fallback (" + e.message + ")"}}}fun main() = runBlocking {// weather fails, but news and stocks still render.loadWidgets().forEach(::println)}
supervisorScope isolates failures: one async can fail without cancelling its siblings. Await each Deferred in its own try/catch and substitute a fallback, but always rethrow CancellationException first so cancellation still propagates. Runs on plain kotlinx.coroutines.
Arena IDEimport kotlinx.coroutines.*suspend fun slowCall(name: String, ms: Long): String {delay(ms) // suspension point: throws CancellationException if cancelledreturn name}fun main() = runBlocking {val request = launch {coroutineScope {val fast = async { slowCall("A", 200) }val slow = async { slowCall("B", 5000) }println(fast.await() + " " + slow.await())}}delay(500) // client disconnects while B is still runningrequest.cancelAndJoin()println("request cancelled -> both async children were stopped")}
Cancellation propagation: when the request coroutine is cancelled (here simulated by cancelling the job mid-flight), structured concurrency stops every child at its next suspension point. delay() is a cooperative cancellation point.
Arena IDE๐ง Check your understanding
0/1 ยท 0/1 answered1. In a suspend handler that fans out three parallel async calls, what is the key difference between wrapping them in coroutineScope { } versus supervisorScope { }?