Kotlin's select Clause Solves the Partial-Results Problem That awaitAll Can't
theme: hydrogen highlight: xcode
In Kotlin coroutines, select occupies a somewhat awkward position.
Most developers have used launch, async, Flow, and perhaps Channel, but few have actually used select in their projects.
Many people who have written Kotlin for years have only occasionally seen it in official documentation or source code.
Of course, developers are not entirely to blame for this.
The official stance on select has always been somewhat ambiguous: select itself is already a stable API, and commonly used functions like onAwait and onReceive can be used normally, but onTimeout is still marked with @ExperimentalCoroutinesApi, and lower-level interfaces carry @InternalCoroutinesApi.
Mixing these APIs together easily raises a question:
Can
selectbe safely used in projects?
Furthermore, since using async + awaitAll, Flow, or Channel seems to solve most asynchronous problems, many people, even if they know about select, rarely study the scenarios where it is suitable.
One additional point: if you check the official website, it says select is Experimental, but if you call the select API, you can use it directly without adding any experimental declarations. Considering the website's last update was at the end of 2024, this is not surprising.
I had a similar feeling about it before: I knew it existed, but I always felt it was hard to encounter a situation where it was indispensable.
Until one day, I encountered this requirement: launch multiple asynchronous tasks simultaneously, but instead of waiting for all of them to finish, process the result of whichever one completes first.
For example, we are developing an e-commerce application. After a user enters the order settlement page, the application needs to query shipping costs from SF Express, JD Logistics, and YTO simultaneously. The page cannot keep waiting for the slowest one. Within 3 seconds, whichever ones return should be displayed first; if none return, then continue waiting for the first available quote.
If you only use awaitAll() for this scenario, handling it is not as smooth as you might imagine.
Below, starting from this shipping quote feature, let's see what problem select actually solves and why it is more suitable than awaitAll() in certain scenarios.
Start
Suppose we are developing an e-commerce application.
After a user enters the order settlement page, the application needs to query shipping costs from SF Express, JD Logistics, and YTO simultaneously, then display the available shipping options.
This code is easy to write initially: launch an asynchronous task for each logistics company, and finally call awaitAll() to wait for all results.
But the product team added another requirement: the settlement page cannot keep spinning just to gather all quotes; the centralized waiting time is at most 3 seconds. Within 3 seconds, display whichever ones have returned; if none return within 3 seconds, continue waiting for the first available quote instead of always waiting for all three.
Now there's a problem.
awaitAll() must wait for all tasks to finish, and simply wrapping it with withTimeoutOrNull() means you cannot get the partially completed results after a timeout.
In this article, we will start from this problem and see what Kotlin's select is truly suitable for solving.
First, Simulate Three Logistics Companies
To make the running results more intuitive, let's first simulate three quote interfaces:
private data class QuoteProvider(
val name: String,
val delayMillis: Long,
val price: Int?,
)
private val providers = listOf(
QuoteProvider(name = "SF Express", delayMillis = 600, price = 18),
QuoteProvider(name = "JD Logistics", delayMillis = 1_400, price = null),
QuoteProvider(name = "YTO", delayMillis = 5_000, price = 12),
)
private suspend fun requestQuote(provider: QuoteProvider): Int? {
delay(provider.delayMillis)
return provider.price
}
Here, delayMillis simulates network latency, and price represents the final returned shipping cost.
JD Logistics' quote is null, which can be interpreted as delivery not being supported for the current address, or this query not returning a valid result.
The response situations for the three logistics companies are as follows:
- SF Express: returns 18 yuan after 600 ms;
- JD Logistics: returns
nullafter 1400 ms; - YTO: returns 12 yuan after 5000 ms.
Now, use the most common async + awaitAll for parallel querying:
private suspend fun loadAllQuotes(): Map<String, Int> = coroutineScope {
providers
.map { provider ->
async {
provider.name to requestQuote(provider)
}
}
.awaitAll()
.mapNotNull { (name, price) ->
price?.let { name to it }
}
.toMap()
}
The three requests indeed start simultaneously, but awaitAll() waits until the last task completes before returning. Therefore, we only get the result after about 5 seconds:
{SF Express=18, YTO=12}
The code is fine, but waiting 5 seconds for the settlement page is obviously a bit long.
What About Adding a Timeout to awaitAll?
Soon, we might think of wrapping it with withTimeoutOrNull():
private suspend fun loadQuotesWithSimpleTimeout(): Map<String, Int> =
withTimeoutOrNull(3.seconds) {
loadAllQuotes()
}.orEmpty()
After 3 seconds, the function does return, but the result is:
{}
SF Express only took 600 ms, so why wasn't its quote kept?
Because awaitAll() returns a complete result set. Since YTO hasn't finished, it won't hand over the intermediate results to us.
After 3 seconds, the entire code block is cancelled, and withTimeoutOrNull() can only return null in the end, which is then turned into an empty Map by orEmpty().
Strictly speaking, the Deferred corresponding to SF Express has indeed completed, and its result hasn't disappeared.
But the fundamental problem here is: awaitAll() does not provide a result set of partially completed tasks.
We need a different waiting method: instead of waiting for all tasks to finish together, process whichever one finishes first.
This is exactly what select excels at.
First, See What select Can Do
Let's start with the simplest single select:
private suspend fun awaitNext(
pending: Map<String, Deferred<Int?>>,
): Pair<String, Int?> = select { // select is here!!!
pending.forEach { (name, deferred) ->
deferred.onAwait { price ->
name to price
}
}
}
This code might look unfamiliar; let's break it down.
pending holds the asynchronous tasks that still need to be waited on. When iterating over it, we tell select via onAwait:
When this
Deferredcompletes, it also counts as a result that can be returned.
These onAwait calls are the candidate branches for select, also referred to as selection clauses in the official documentation.
When any Deferred completes, the corresponding onAwait is selected, and select immediately returns the logistics company name and quote.
But note: a single select call selects at most one result.
Assuming SF Express returns first, awaitNext() gets:
(SF Express, 18)
As for JD Logistics and YTO, they won't stop executing just because they weren't selected this time. The two requests are still ongoing, and when select is called again later, their results can still be waited on.
This is also a very important characteristic of select: if not selected by select, the task is not cancelled.
There's another small detail: ordinary select is biased. If multiple branches are ready simultaneously, the branch registered earlier takes priority. If random selection is truly needed, selectUnbiased can be used.
When collecting all quotes, this usually only affects the processing order; but when taking only one result, it might affect the final choice.
Put It in a Loop
Our goal is not just to get the fastest one, but to collect all valid quotes returned within 3 seconds.
Since a single select can only return one result, execute it multiple times:
- Wait for the next result from the unprocessed tasks;
- Save the valid quote;
- Remove the completed task from the waiting list;
- Continue waiting for the next one, until all are complete or a timeout occurs.
The code is as follows:
First, create the pending tasks, with the return type being Map<String, Deferred<Int?>>.
val pending = providers.associate { provider ->
provider.name to async { requestQuote(provider) }
}
private suspend fun collectQuotesWithin(
pending: Map<String, Deferred<Int?>>,
timeout: Duration,
): Map<String, Int> {
val remaining = pending.toMutableMap()
val collected = mutableMapOf<String, Int>()
withTimeoutOrNull(timeout) {
while (remaining.isNotEmpty()) {
val (name, price) = awaitNext(remaining) // awaitNext was implemented above
remaining.remove(name)
if (price != null) {
collected[name] = price
}
}
}
return collected
}
This time, the collected map for saving quotes is created outside the timeout code block.
Before the 3 seconds are up, every time select gets a result, we process it immediately. Even if a timeout is triggered later, the quotes already written into collected can be returned normally.
Still using the previous three logistics services, if we test this code, the running result becomes:
600 ms: SF Express returns 18 yuan, saved
1400 ms: JD Logistics returns null, ignored
3004 ms: Waiting times out, returning existing results
Final result: {SF Express=18}
YTO needs 5 seconds to complete, so it didn't enter this round of returned results.
ConcurrentHashMap is also not needed here. The requests for the three logistics companies are indeed executed in parallel by different coroutines, but the code for reading results, writing to collected, and removing elements from remaining all runs within the current coroutine, executed one after another, so a regular MutableMap is sufficient.
Additionally, the onAwait calls in select don't need to be written manually one by one. The code block of select is essentially a builder lambda with SelectBuilder as the receiver, so candidate branches can be dynamically added by iterating over a collection, just like in awaitNext().
What If None Return?
Now consider a worse situation: all three logistics companies are very slow, and none return within 3 seconds.
If an empty result is returned directly, the settlement page will have no delivery methods. So a fallback strategy is adopted here: after 3 seconds, stop waiting for all quotes and only wait for the first valid quote. The default assumption here is that each quote interface has its own timeout boundary and won't execute forever; this premise will be specifically explained later.
Assume the response situations for the three logistics companies this time become:
- JD Logistics: returns
nullafter 3600 ms; - SF Express: returns 18 yuan after 4200 ms;
- YTO: returns 12 yuan after 5000 ms.
Nothing can be collected in the first 3 seconds. After entering the fallback phase, although JD Logistics completes first, it returns null and cannot be used; continue waiting until 4200 ms, SF Express returns 18 yuan, and we can stop at this point.
Let's first implement the logic for returning the first one, meaning as soon as one returns, we stop collecting subsequent data. The implementation still uses the previous awaitNext():
private suspend fun awaitFirstNonNull(
pending: Map<String, Deferred<Int?>>,
): Pair<String, Int>? {
val remaining = pending.toMutableMap()
while (remaining.isNotEmpty()) {
val (name, price) = awaitNext(remaining)
remaining.remove(name)
if (price != null) {
return name to price
}
}
return null
}
This code is very similar to the previous collection logic, but the handling after getting a result is different:
- The previous
collectQuotesWithin()saves every valid quote and then continues the loop; - The current
awaitFirstNonNull()returns immediately after finding the first valid quote.
Note: we did not re-initiate the three logistics requests, meaning we did not create new tasks.
The Deferred objects for the three logistics companies are only created once. After the first phase times out, the unfinished requests are still running, and the second phase just continues waiting for them. This is also why it was specifically emphasized earlier: select not selecting a task does not mean that task is cancelled.
Combining the Two Phases
Now combine "collect as much as possible within 3 seconds" and "wait for the first valid quote when there are no results":
suspend fun loadQuotes(): Map<String, Int> = coroutineScope {
val pending = providers.associate { provider ->
provider.name to async {
requestQuote(provider)
}
}
try {
val collected = collectQuotesWithin(
pending = pending,
timeout = 3.seconds,
)
if (collected.isNotEmpty()) {
collected
} else {
val first = awaitFirstNonNull(pending)
first
?.let { (name, price) -> mapOf(name to price) }
.orEmpty()
}
} finally {
// Although this operation seems inconspicuous, it is very important.
// If the remaining tasks are no longer needed, they should be cancelled.
pending.values.forEach { deferred ->
deferred.cancel()
}
}
}
All requests are executed in parallel via async from the start.
In the first 3 seconds, collectQuotesWithin() tries its best to collect valid quotes that have already returned. As long as one or more results are obtained, they are returned directly; if the result is empty, it enters awaitFirstNonNull() to continue waiting for the first available quote.
Finally, regardless of whether the function returns normally or an exception occurs, finally cancels the tasks that are no longer needed. Calling cancel() on an already completed Deferred causes no issues; unfinished requests will not continue to waste resources.
There's a crucial scoping relationship here: the Deferred objects in pending are created in the outer coroutineScope, while withTimeoutOrNull() only wraps the first phase's collection loop. Therefore, when the 3-second timeout occurs, what is stopped is the collection process, not the logistics requests already launched in the outer scope. This is why we can continue reusing the same batch of Deferred objects in the second phase.
Why select Is Suitable Here
This problem certainly doesn't have only a select-based solution.
We could launch a launch for each logistics company and write the results into a concurrent container; or we could have each request send its result to a Channel.
These solutions can all be implemented, but using select in the current scenario seems simpler (if you understand select):
- You already have a set of executing
Deferredobjects in hand, which can be directly waited on viaonAwait; - Only one completed result is processed at a time, so a regular local
MutableMapis sufficient; - After the first phase ends, the unfinished tasks still need to be used, and
selectdoes not automatically cancel unselectedDeferredobjects.
This is not to say that Channel or launch is bad.
If data is produced continuously and requires long-term communication between producers and consumers, Channel is more natural; if each result needs further parallel processing upon arrival, using multiple launch instances with thread-safe data structures is also appropriate.
The key here is how the current data is produced and how the results need to be processed.
select Can Wait for More Than Just Deferred
The previous code consistently used onAwait because our quote requests were represented by Deferred.
select can also wait for other types of events:
Channel.onReceive: wait to receive data from aChannel;Channel.onReceiveCatching: receive data while also handling the case where theChannelis closed;Channel.onSend: wait until aChannelcan send data;Job.onJoin: wait for aJobto complete.
For example, a coroutine can both wait for a background task to return and simultaneously wait for a message to appear in a Channel. Whichever is ready first, select executes that branch first.
Two Easily Overlooked Issues
A Failed Deferred Does Not Automatically Become null
The requestQuote() in the example only returns a price or null, and does not throw exceptions.
Real network requests are not necessarily like this. If a Deferred ends with an exception, using onAwait on it will cause the exception to continue propagating outward. In a regular coroutineScope, a child coroutine failing can also cancel other child coroutines.
Therefore, production code should clearly define which errors can be converted to null.
For example, expected failures like network unavailability or delivery not being supported in the current region can be handled at the request layer; CancellationException from coroutines should continue to be thrown and not swallowed as a regular request failure.
In other words, "return null on failure" is not a capability provided by select itself; we developers need to define the return rules for the interface.
Fallback Waiting Also Needs Boundaries
awaitFirstNonNull() itself has no total timeout. If the underlying request might never end, the fallback phase could also wait indefinitely.
The current example assumes each quote interface has its own timeout mechanism and will eventually return a price, return null, or throw an exception. In a real project, this condition must be explicit and cannot rely solely on the outermost 3-second collection time.
Some Thoughts
Although this shipping quote feature seems very simple, the requirements still involve many trade-offs in implementation.
async + awaitAll seems fine on the surface, but it can only return results once after all tasks are finished, unable to meet the requirement of "display however many return within 3 seconds."
Simply adding withTimeoutOrNull() only limits the waiting time but cannot get the partial results that awaitAll() hasn't finished assembling.
select, on the other hand, can return immediately when any Deferred completes. Since it can only take one at a time, we add a loop outside; if there are no valid results within 3 seconds, we continue waiting for the first non-null result.
So, in the future, when encountering multiple asynchronous tasks, first think clearly about one thing: do we want to wait for all of them to complete, or do we want to process them one by one in order of completion?
If the answer is the latter, select is very likely the right tool for the job.