The task shapes the strategy: Kotlin select expressions in practice
How changing requirements reshapes async coordination in Kotlin — a walkthrough with select expressions
The task shapes the strategy: Kotlin select expressions in practice
Code that lives long enough will see its requirements change more than once. Some of those changes land on async data handling: a straightforward parallel fetch grows to include timeouts, fallback logic, partial results. This article is about how the choice of strategy follows the change in task, using select expressions as the example.

The core idea
The strategy follows the task, not the sources. The sources can stay the same while the task changes: in one formulation you need to collect everything that arrives in time, in another you need the first suitable result. Switching the strategy here is not an optimization. It is a response to the task changing.
The walkthrough below is built around a single evolving example: the same task changes in three steps, and at each step the choice of strategy changes with it. Select expressions make the difference between “same sources” and “same task” more visible than most other primitives, which is why they are at the center of this piece.
Collecting what arrives in time
Take a production task, simplified for this walkthrough: there are N token delivery channels, each returning either a token or null if the attempt failed. The goal is to query them in parallel and collect whatever comes back. In Kotlin this is three steps: async for each source, awaitAll, filter nulls:
suspend fun collectTokens(): List<String> = coroutineScope {
providers
.map { name -> async { fetchToken(name) } }
.awaitAll()
.filterNotNull()
}
For the original task this is enough. Error handling is left out: we assume fetchToken returns null on failure. For this walkthrough we treat errors as handled at a lower level.
Suppose the requirements change: the call must not hang indefinitely. If not all sources finish within three seconds, return whatever did. The previous approach does not scale to this: awaitAll only returns when every source is done, and wrapping it in withTimeoutOrNull loses the partial result when the timeout fires. At that point, the task itself has changed: not “wait for all to finish” but “collect results as they arrive, for as long as there is time”.
There are several typical solutions: a launch-per-source approach with a ConcurrentHashMap, a producer/consumer setup with Channel, or a select loop. The select loop fits better here for a few reasons:
- It works with
Deferredsdirectly: the async calls are already running, andonAwaitis a first-class clause for them. - The accumulator stays a local MutableMap. Reaching for a concurrent structure here would imply parallelism that isn’t there: everything inside the loop happens sequentially.
- A Channel in the alternative setup becomes a middleman between the
Deferredsand the accumulator, with its own lifecycle that adds no value for a straightforward accumulation task. withTimeoutOrNullwraps the loop itself and cancels it naturally when it fires. In the launch-based variant, the timeout only covers the wait for completion, and cancelling the coroutines themselves typically becomes a separate concern.
Now for how the loop works internally. Deferreds for all sources are created once, outside the loop: they run in parallel from the moment async is called. The loop only repeats the select: each call returns one completed source from those not yet processed. After the clause we write the result to the accumulator and remove the source from the set, so the next select does not see it again. The loop runs until the set is empty or the timeout fires.
Here is what the loop looks like in code:
private suspend fun collectWhileTimeRemains(
pending: Map<String, Deferred<String?>>,
timeout: Duration,
): Map<String, String> = buildMap {
withTimeoutOrNull(timeout) {
val remaining = pending.toMutableMap()
while (remaining.isNotEmpty()) {
val (name, value) = select<Pair<String, String?>> {
remaining.forEach { (name, deferred) ->
deferred.onAwait { result -> name to result }
}
}
if (value != null) put(name, value)
remaining.remove(name)
}
}
}
The clauses inside select are built dynamically, via forEach over remaining. The select body takes a receiver lambda of type SelectBuilder<R>.()->Unit, so clauses can be registered any way you like, including a loop over a collection. The clause set changes with each iteration.
buildMap is an inline function, so the compiler treats its lambda as part of the body of the calling suspend function.
Why a loop, not a single select
select picks one winner from whichever sources are ready. Exactly one: as soon as the first completes, the expression returns and exits.
For a simple race that is enough. Launch several requests, wait for the first response. That is how select is typically shown in tutorials.
But in our case we need all results that arrive within the time budget. So the loop: each iteration, select returns one completed source, we write its result to the accumulator and remove it from remaining.
This is elimination. Each iteration narrows the candidate set by one. What happens with the result depends on the task. In one case it is accumulation, in another filtering, in another something else. The select itself stays the same, only the handling after the clause changes.
When nothing makes it in time
Suppose all sources miss the time budget. Returning an empty map is not acceptable. The fallback is to wait for the first source that returns a non-null result. For this example we assume error handling and timeouts are guaranteed at a lower level.
The structure stays the same. Same Deferreds, same select loop. What changes is the behavior after the clause. On a non-null result the function returns immediately and exits the loop. On null it removes the source from remaining and continues.
private suspend fun awaitFirstNonNull(
pending: Map<String, Deferred<String?>>,
): Pair<String, String>? {
val remaining = pending.toMutableMap()
while (remaining.isNotEmpty()) {
val (name, value) = select<Pair<String, String?>> {
remaining.forEach { (name, deferred) ->
deferred.onAwait { result -> name to result }
}
}
if (value != null) return name to value
remaining.remove(name)
}
return null
}
The select body is identical. The two functions differ only in what happens after the clause. In collectWhileTimeRemains the result goes into the map and the loop continues. In awaitFirstNonNull the function returns and exits. Removing the source from remaining is the same in both cases, but in the second function it only happens on null — on a non-null result the function has already returned.
Same sources, same loop structure, same select. Different behavior, because a different decision is made on the clause result: in the first case it is “collect everything that is not null”, in the second it is “find the first non-null”.
The main function brings both phases together:
suspend fun collectTokens(): Map<String, String> = coroutineScope {
val pending = providers.associateWith { name ->
async { fetchToken(name) }
}
val collected = collectWhileTimeRemains(pending, timeout = 3.seconds)
if (collected.isNotEmpty()) {
pending.values.forEach { it.cancel() }
return@coroutineScope collected
}
val first = awaitFirstNonNull(pending)
pending.values.forEach { it.cancel() }
first?.let { (name, token) -> mapOf(name to token) }.orEmpty()
}
The switch happens at if (collected.isNotEmpty()). Before it, the decision was “collect what arrives in time”. After it, the task has shifted: “wait for the first non-null”. The sources have not changed. The strategy has. Same select loop construction, different handling after the clause result.
Both phases are wrapped in functions with descriptive names: collectWhileTimeRemains and awaitFirstNonNull. Each expresses a concrete decision and hides the select loop as an implementation detail. In production code select typically lives inside wrapper functions, not directly in business logic.
What select properties mean for tool choice
To choose a tool deliberately, it helps to understand its properties. Each property shapes which tasks the tool fits and where another approach works better. We have already walked through select as the main example. Now let’s look at how its properties inform the choice.
Single point of execution. The select loop runs in one coroutine, sequentially. Writing to the map, returning, removing from remaining all happen without synchronization. Cancellation works the same way: one coroutine stops, and the state of the loop at that moment determines the result.
What this means for tool choice: sources run in parallel, but result handling is sequential, all in one coroutine. That is the core pattern for select. If the task requires parallel result processing or concurrent writes to a shared container, select has no advantage over launch with a concurrent structure.
Deregistration is not cancellation. When select picks one clause, it deregisters the rest. But the sources themselves keep running: a Deferred completes its work, its result stays available, and it can be picked up by the next select if needed. This is what happens between the two phases in the code above: Deferreds that have not yet completed are reused. They have been running since async was called, and the second select will see them once they complete.
What this means for tool choice: after a result is received, the losing sources keep running. That is useful when they are needed later, as in the pattern above where the second phase relies on the same Deferreds. If the sources are not needed after the first response, cancelling them becomes a separate concern.
Source type flexibility. The code above uses onAwait throughout because the sources are Deferreds. Select has clauses for other types as well: Channel.onReceive, Channel.onReceiveCatching, Channel.onSend, Job.onJoin. Same primitive, same coding style.
What this means for tool choice: the same select loop works for tasks where sources are of different kinds. For example, waiting for either the result of a background job, or a message from a channel, or a Job completing. That opens select up to a wider range of tasks.
A note on experimental status
Select is sometimes described as an experimental API altogether, but that is not accurate. The select expression itself and its core clauses (onAwait, onReceive, onReceiveCatching, onSend) are stable. The onTimeout clause is marked @ExperimentalCoroutinesApi. The low-level clause API is marked @InternalCoroutinesApi and is intended for those writing their own select-compatible primitives.
These annotations mean “this API may still change”, not “this does not work”. Worth keeping in mind when using select in a public library or SDK signature. API stability in kotlinx.coroutines can shift between releases, so the current status is worth checking in the latest documentation.
Wrapping up
When requirements change, what changes is rarely the set of sources. It’s the decision the block makes on their results. In the pattern above, the shift from “collect what arrives in time” to “find the first viable result” happens at a single if statement. That one line is where the task changed. The technique stays the same: both phases run on the same select loop, and only the handling after the clause result changes.
Select, then, is less a tool you reach for than a fit you recognize. Knowing about select matters less than knowing when the task calls for it.
When reading a concurrent block, one question is worth asking: what decision is this block making? The answer shapes the approach. If the answer does not match what the code does, it usually means the task has changed and the code has not caught up.
The full runnable example with both scenarios is available as a GitHub Gist.
메타데이터
- post_id
- b98bc53b5f15
- slug
- the-task-shapes-the-strategy-kotlin-select-expressions-in-practice-b98bc53b5f15
- url
- https://proandroiddev.com/the-task-shapes-the-strategy-kotlin-select-expressions-in-practice-b98bc53b5f15
- canonical_url
- https://proandroiddev.com/the-task-shapes-the-strategy-kotlin-select-expressions-in-practice-b98bc53b5f15
- author_url
- https://medium.com/@chdanilr
- status
- ok
- fetched_at
- 2026-06-12 10:20:10