I shipped a dashboard screen last year that loaded user profile, recent orders, and notification count. Three API calls. Each took about a second. Users stared at a loading spinner for 3 full seconds.
The embarrassing part? Those calls had zero dependencies on each other. They could've run in parallel. Total load time should've been 1 second, not 3.
I've since learned there are three main ways to run parallel operations in Kotlin: async/awaitAll, Flow.zip, and Flow.combine. They look similar but behave very differently. Took me a while to figure out when to use which.
The Sequential Trap
Here's what my original code looked like:
fun loadDashboard() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val profileResult = runCatching { getProfile() }
val ordersResult = runCatching { getRecentOrders() }
val notificationsResult = runCatching { getNotificationCount() }
// Handle results...
_uiState.update { it.copy(isLoading = false) }
}
}
Looks fine, right? Clean, readable, handles errors. But here's the problem—each runCatching block waits for the previous one to complete. Sequential execution.
getProfile() [====1 sec====]
getRecentOrders() [====1 sec====]
getNotifications() [====1 sec====]
|_____________ 3 seconds _______________|
Users don't care about my clean code. They care that the screen loads fast.
async/awaitAll: The Straightforward Fix
When you just need to fire multiple suspend functions and wait for all of them, async is your friend:
suspend fun loadDashboard(): DashboardData = coroutineScope {
val profileDeferred = async { getProfile() }
val ordersDeferred = async { getRecentOrders() }
val notificationsDeferred = async { getNotificationCount() }
DashboardData(
profile = profileDeferred.await(),
orders = ordersDeferred.await(),
notifications = notificationsDeferred.await()
)
}
Now all three calls start immediately. The total time is however long the slowest one takes.
getProfile() [====1 sec====]
getRecentOrders() [====1 sec====]
getNotifications() [====1 sec====]
|__ 1 second __|
3 seconds down to 1. Users notice this stuff.
If you want to be more explicit, awaitAll does the same thing:
suspend fun loadDashboard(): DashboardData = coroutineScope {
val (profile, orders, notifications) = awaitAll(
async { getProfile() },
async { getRecentOrders() },
async { getNotificationCount() }
)
DashboardData(
profile = profile as UserProfile,
orders = orders as List<Order>,
notifications = notifications as Int
)
}
The cast is ugly and IntelliJ screams at you with warnings. I stick with the first approach.
Error Handling Gets Tricky
Here's something that bit me. If one async block throws, the whole coroutineScope fails. Sometimes that's what you want. Sometimes it isn't.
For my dashboard, I wanted partial results. If notifications fail, I still want to show profile and orders. Here's how I handle it:
suspend fun loadDashboard(): DashboardData = coroutineScope {
val profileDeferred = async {
runCatching { getProfile() }.getOrNull()
}
val ordersDeferred = async {
runCatching { getRecentOrders() }.getOrElse { emptyList() }
}
val notificationsDeferred = async {
runCatching { getNotificationCount() }.getOrElse { 0 }
}
DashboardData(
profile = profileDeferred.await(),
orders = ordersDeferred.await(),
notifications = notificationsDeferred.await()
)
}
Each call handles its own failure. The dashboard still loads even if one API is down.
Flow.zip: When Results Belong Together
async is great for one-shot operations. But sometimes you're working with Flows—maybe because you want to retry automatically, or you're combining with other reactive streams.
That's where zip comes in:
fun loadSettingsScreen(): Flow<SettingsScreenData> {
val userPrefsFlow = flow { emit(getUserPreferences()) }
val appConfigFlow = flow { emit(getAppConfig()) }
return userPrefsFlow.zip(appConfigFlow) { prefs, config ->
SettingsScreenData(
theme = prefs.theme,
notifications = prefs.notificationsEnabled,
version = config.appVersion,
features = config.enabledFeatures
)
}
}
Both flows run in parallel. zip waits for both to emit, then combines them. Took me a while to realize that—I kept wondering why my UI wasn't updating.
The "Pairing" Behavior
Here's the thing about zip that confused me initially. It pairs emissions by order. First emission from Flow A pairs with first emission from Flow B. Second with second. And so on.
For one-shot API calls wrapped in flow { emit(...) }, this doesn't matter—each flow emits once.
But if you have continuous streams:
val pricesFlow = getPriceUpdates() // Emits: 100, 102, 105, 103...
val inventoryFlow = getInventoryUpdates() // Emits: 50, 48, 52...
pricesFlow.zip(inventoryFlow) { price, inventory ->
StockInfo(price, inventory)
}
// Result: (100, 50), (102, 48), (105, 52)...
Each price pairs with the corresponding inventory update. If one flow emits faster than the other, the faster one waits.
This is usually what you want for paired data. But sometimes it's not.
Flow.combine: Latest Values, Real-time Updates
combine doesn't pair by order. It takes the latest value from each flow whenever any of them emits.
val cartFlow = getCartUpdates() // User adds/removes items
val discountFlow = getActiveDiscounts() // Promotions change
val shippingFlow = getShippingOptions() // Based on address
combine(cartFlow, discountFlow, shippingFlow) { cart, discounts, shipping ->
val discountAmount = calculateDiscount(cart, discounts)
CheckoutSummary(
subtotal = cart.total,
discount = discountAmount,
shippingCost = shipping.selectedOption.price,
total = cart.total - discountAmount + shipping.selectedOption.price
)
}
User adds an item to cart? New emission with latest discount and shipping values. Discount expires? New emission with latest cart and shipping. Everything stays in sync automatically.
The Dashboard Badge Problem
I had a header bar showing notification count and cart item count. Two separate API endpoints. I needed the UI to update whenever either changed.
First attempt with zip:
// Wrong approach
notificationCountFlow.zip(cartCountFlow) { notifications, cartItems ->
HeaderState(notifications, cartItems)
}
This broke. If the user added something to cart, the header didn't update until a new notification arrived. zip was waiting for the other flow.
Fixed with combine:
combine(notificationCountFlow, cartCountFlow) { notifications, cartItems ->
HeaderState(notifications, cartItems)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = HeaderState(0, 0)
)
Now either change triggers an update with the latest values from both.
Real-World Example: Account Screen
Here's a pattern I use often. Account screen needs user profile, subscription status, and linked accounts. All independent API calls.
@HiltViewModel
class AccountViewModel @Inject constructor(
private val getProfile: GetUserProfile,
private val getSubscription: GetSubscriptionStatus,
private val getLinkedAccounts: GetLinkedAccounts
) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
fun loadAccount() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = runCatching {
coroutineScope {
val profile = async { getProfile() }
val subscription = async { getSubscription() }
val linked = async { getLinkedAccounts() }
AccountData(
profile = profile.await(),
subscription = subscription.await(),
linkedAccounts = linked.await()
)
}
}
result.onSuccess { data ->
_uiState.update {
it.copy(
isLoading = false,
profile = data.profile,
subscription = data.subscription,
linkedAccounts = data.linkedAccounts
)
}
}.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, error = error.message)
}
}
}
}
}
Three network calls, one loading spinner, roughly one-third the wait time.
Search with Debounce: A Different Pattern
Not everything should run in parallel. Search is a good example:
private val _searchQuery = MutableStateFlow("")
init {
viewModelScope.launch {
_searchQuery
.debounce(400)
.filter { it.length >= 2 }
.distinctUntilChanged()
.collectLatest { query ->
searchProducts(query)
}
}
}
collectLatest cancels the previous search if a new query comes in before it finishes. User types "sho", waits, types "shoe"—only "shoe" search runs.
I tried making this parallel once. Don't. You'll get race conditions where "sho" results arrive after "shoe" results and overwrite them. Sequential with cancellation is correct here.
So Which One Do I Actually Use?
Honestly, async covers 80% of my cases. Screen loads, form submissions, any place where I'm fetching data once and done. It's simple and it works.
zip I rarely reach for. It's useful when you genuinely have paired data—like matching timestamps from two sensors, or correlating request/response logs. Most of the time I don't have that kind of data.
combine is for reactive UI stuff. Header badges, dashboard widgets, anything where multiple data sources feed into one piece of UI that should update live. If you're using stateIn a lot, you probably want combine.
And sequential? That's the default. Don't overthink it. If one call needs data from another, just do them in order. Search is the classic example—you want cancellation there, not parallelism.
The Performance Difference is Real
I measured my dashboard before and after:
| Approach | Load Time | API Calls |
|---|---|---|
| Sequential | ~2800ms | 3 |
| Parallel (async) | ~980ms | 3 |
Same data. Same network. Almost 3x faster just by changing how I structured the coroutines.
Users might not consciously notice the difference between 1 second and 3 seconds. But they feel it. The app feels snappier. They don't get impatient and tap things twice. Small wins add up.
One More Thing
If you're using parallel calls with error handling, consider what "failure" means for your screen. Do you want all-or-nothing? Partial results? Retry logic?
For critical data (user profile on account screen), I fail the whole load if it fails. For supplementary data (recommendation widgets), I show empty state and let the rest of the screen render.
There's no universal answer. Think about what your users need to accomplish on that screen.
My dashboard loads in a second now. Should've fixed it six months earlier. A user even emailed me saying the app "feels faster after the last update." All I did was add three async blocks. Sometimes the boring optimization work is the most impactful.