DEV Community

Riazul Karim Ivan
Riazul Karim Ivan

Posted on

The Ultimate Guide to Kotlin Concurrency for Building a Super Android App 🚀

How We Use Kotlin Coroutines & Flow in Enterprise Android

In this article, I'll show how we use Coroutines + Flow in production inside a digital wealth management app. This is not theoretical coroutine usage.

This is orchestration for:

  • Portfolio summary, Investment accounts
  • Risk profile validation, Regulatory document loading
  • Sensitive balance visibility toggle
  • Parallel dashboard aggregation
  • Child dependent API triggers
  • Compose-driven UI state
  • Backpressure handling
  • Error handling patterns
  • Token refresh flows
  • Retry policies
  • App preloading optimization

Architecture Context

  • Clean Architecture / MVVM / MVI
  • Repository pattern
  • UseCase returns Flow
  • ViewModel orchestrates flows
  • Immutable UI state via StateFlow
  • Compose collects state
  • Retrofit + Room
  • No GlobalScope
  • No blocking calls

1. Sequential Orchestration (Profile → Portfolio)

Real Scenario

When user opens Portfolio screen:

  1. Load investor profile
  2. Update greeting header
  3. Fetch portfolio accounts
  4. Map into UI state
  5. Handle stage-specific errors

ViewModel Implementation

private fun loadInvestorOverview() {

    getInvestorProfileUseCase
        .execute(Unit)
        .onStart { setLoading(true) }
        .catch { error ->
            setLoading(false)
            showInvestorError(error)
        }
        .flatMapConcat { profile ->
            _uiState.update {
                it.copy(
                    investorName = "${profile.firstName} ${profile.lastName}"
                )
            }
            getPortfolioAccountsUseCase.execute(profile.investorId)
        }
        .catch { error ->
            setLoading(false)
            handlePortfolioError(error)
        }
        .onEach { accounts ->
            _uiState.update {
                it.copy(
                    portfolioAccounts = accounts.map {
                        PortfolioItem(
                            accountId = it.accountId,
                            productType = it.productType
                        )
                    }
                )
            }
        }
        .onCompletion { setLoading(false) }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern Is Important

  • First error handles profile domain
  • Second error handles portfolio domain
  • No nested coroutine blocks
  • Pipeline remains flat and readable
  • Cancellation remains structured

This is how real fintech orchestration should look.


2. Parallel Dashboard Aggregation (combine)

Scenario

On dashboard load:

  • Fetch investment accounts
  • Fetch total asset value Show screen only when both available
fun initializeDashboard() {

    val accountsFlow = getInvestmentAccountsUseCase.execute(Unit)
    val assetValueFlow = getTotalAssetValueUseCase.execute(Unit)
    accountsFlow
        .combine(assetValueFlow) { accounts, totalValue ->
            accounts to totalValue
        }
        .onStart { setLoading(true) }
        .onCompletion { setLoading(false) }
        .catch { showDashboardError(it) }
        .onEach { (accounts, totalValue) ->
            _uiState.update {
                it.copy(
                    investmentAccounts = accounts,
                    totalAssets = totalValue
                )
            }
        }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Why combine instead of zip?

combine reacts when either emits.
Asset value might refresh independently of account list.
In real apps:

  • Accounts rarely change
  • Market value changes frequently

combine supports this naturally.


3. Strict Regulatory Pairing (zip)

Scenario

Before showing Trade screen:

  • Load risk disclosure template
  • Load risk summary view template

Both must succeed.

private fun loadRiskDocuments() {

    getDisclosureTemplateUseCase.execute(DISCLOSURE_FULL)
        .zip(
            getDisclosureTemplateUseCase.execute(DISCLOSURE_SUMMARY)
        ) { full, summary ->
            full to summary
        }
        .onStart { setLoading(true) }
        .onCompletion { setLoading(false) }
        .catch { showDocumentError(it) }
        .onEach { (fullDoc, summaryDoc) ->
            _uiState.update {
                it.copy(
                    fullDisclosure = fullDoc,
                    summaryDisclosure = summaryDoc
                )
            }
        }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Why zip here?

Because both documents are mandatory before proceeding.
No partial rendering allowed.


4. Detail Loading + Dependent Call

Scenario

When user selects an investment account:

  1. Load account details
  2. Update state
  3. Trigger transaction history call
fun loadAccountDetail(accountId: String, refresh: Boolean = false) {

    getAccountDetailUseCase
        .execute(accountId)
        .onStart { setLoading(true) }
        .onCompletion {
            _uiState.update { it.copy(isRefreshing = false) }
        }
        .catch { handleDetailError(it) }
        .onEach { detail ->
            _uiState.update {
                it.copy(
                    selectedAccount = detail,
                    hasDividendOption = detail.dividendOptions.isNotEmpty()
                )
            }
            loadTransactionHistory(accountId, refresh)
        }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Why trigger child call inside onEach?

Because:

  • Only executed after success
  • No nested coroutineScope
  • Keeps orchestration centralized in ViewModel

5. UI State for Compose (Immutable)

data class PortfolioUiState(
    val isLoading: Boolean = false,
    val investorName: String = "",
    val investmentAccounts: List<PortfolioItem> = emptyList(),
    val totalAssets: AssetValue? = null,
    val selectedAccount: AccountDetail? = null,
    val hasDividendOption: Boolean = false,
    val fullDisclosure: Document? = null,
    val summaryDisclosure: Document? = null
)
Enter fullscreen mode Exit fullscreen mode

ViewModel State Holder

private val _uiState = MutableStateFlow(PortfolioUiState())
val uiState: StateFlow<PortfolioUiState> = _uiState
Enter fullscreen mode Exit fullscreen mode

6. Compose UI (Reactive + Clean)

@Composable
fun PortfolioScreen(viewModel: PortfolioViewModel) {

    val state by viewModel.uiState.collectAsStateWithLifecycle()
    if (state.isLoading) {
        CircularProgressIndicator()
    }
    Text(text = "Welcome ${state.investorName}")

    state.totalAssets?.let {
        Text("Total Assets: ${it.formatted}")
    }

    LazyColumn {
        items(state.investmentAccounts) { account ->
            Text(account.accountId)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No LiveData and No manual observers.
Pure Flow ----> StateFlow ----> Compose.


7. Reactive Sensitive Value Visibility (Flow-Based)

Instead of Rx sensor logic, we use Flow:

Scenario:

  • Hide portfolio value when device is face down
  • Show when user touches screen
val sensitiveVisibilityFlow =
    combine(deviceOrientationFlow, userInteractionFlow) { isFaceDown, isTouching ->
        !isFaceDown && isTouching
    }.distinctUntilChanged()
In ViewModel:
sensitiveVisibilityFlow
    .onEach { visible ->
        _uiState.update {
            it.copy(isPortfolioVisible = visible)
        }
    }
    .launchIn(viewModelScope)
Compose:
AnimatedVisibility(visible = state.isPortfolioVisible) {
    PortfolioValueSection()
}
Enter fullscreen mode Exit fullscreen mode

8. Handling Inactivity (PIN / Login Expire)

Use a shared flow for session events.

object SessionManager {
    private val _sessionExpired = MutableSharedFlow<Unit>()
    val sessionExpired = _sessionExpired.asSharedFlow()

    suspend fun expire() {
        _sessionExpired.emit(Unit)
    }
}
Enter fullscreen mode Exit fullscreen mode

In BaseViewModel:

viewModelScope.launch {
    SessionManager.sessionExpired.collect {
        navigator.navigateToLogin()
    }
}
Enter fullscreen mode Exit fullscreen mode

9. Prevent Multiple Button Clicks (Throttle)

Double execution is not a fintech issue.
It's a concurrency failure - and every serious app must prevent it.

Throttle First

fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
    var lastTime = 0L
    collect { value ->
        val current = System.currentTimeMillis()
        if (current - lastTime >= windowDuration) {
            lastTime = current
            emit(value)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

buttonClicks
    .throttleFirst(1000)
    .onEach { viewModel.sendMoney() }
    .launchIn(scope)
Enter fullscreen mode Exit fullscreen mode

10. Backpressure Handling (High Frequency Events)

Example:

  • Search field
  • Real-time stock ticker

Debounce + FlatMapLatest

searchQuery
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { query ->
        repository.searchStocks(query)
    }
Enter fullscreen mode Exit fullscreen mode

flatMapLatest cancels previous request.


11. Avoid Blocking User Experience

Never:

runBlocking { }
Thread.sleep()
Enter fullscreen mode Exit fullscreen mode

Instead:

withContext(Dispatchers.IO) {
    heavyWork()
}
Enter fullscreen mode Exit fullscreen mode

Or better - push heavy work to repository layer.


12. Jetpack Compose Concurrency Patterns

Collect State Safely

val balance by viewModel.balanceFlow.collectAsStateWithLifecycle()
Enter fullscreen mode Exit fullscreen mode

Launch Side Effects

LaunchedEffect(Unit) {
    viewModel.loadDashboard()
}
Enter fullscreen mode Exit fullscreen mode

Snackbar Error Flow

LaunchedEffect(viewModel.errorFlow) {
    viewModel.errorFlow.collect {
        snackbarHostState.showSnackbar(it.message)
    }
}
Enter fullscreen mode Exit fullscreen mode

13. Advanced Retry with Exponential Backoff

In financial systems:

  • Network instability is common
  • Backend throttling happens
  • Temporary 5xx failures occur
  • You must retry safely - but not aggressively

Retry is not retry(3).
Retry must be:

  • Conditional
  • Intelligent
  • Exponential
  • Cancellable
  • Token-aware

13.1 Production Pattern: Conditional Exponential Backoff

Scenario

Refreshing portfolio valuation from market service.

private fun refreshMarketValuation() {

    getMarketValuationUseCase
        .execute(Unit)
        .retryWhen { cause, attempt ->
            val isNetworkError = cause is IOException
            val isServerError = cause is HttpException && cause.code() >= 500
            if ((isNetworkError || isServerError) && attempt < 3) {
                val backoffDelay = 1_000L * (2.0.pow(attempt.toDouble())).toLong()
                delay(backoffDelay)
                true
            } else {
                false
            }
        }
        .onStart { setLoading(true) }
        .onCompletion { setLoading(false) }
        .catch { handleMarketError(it) }
        .onEach { valuation ->
            _uiState.update { it.copy(marketValuation = valuation) }
        }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

What Happens Here?

  1. Retries only network + 5xx
  2. Does NOT retry business errors (4xx)
  3. Exponential backoff: 1s → 2s → 4s
  4. Automatically cancelled if ViewModel cleared
  5. No blocking threads

This prevents backend abuse while improving reliability.


14. Token Expiry + Automatic Refresh

In fintech/investment systems:

  • Access tokens expire frequently
  • Multiple API calls may fail simultaneously
  • Only ONE refresh call must execute
  • Other calls must wait

If you don't orchestrate this properly:

  • You trigger multiple refresh requests
  • Backend invalidates sessions
  • Users get forced logout

Core Rule

Token refresh must be:

  • Centralized
  • Mutex-protected
  • Reusable across flows

Token Coordinator

class AuthTokenCoordinator(
    private val refreshTokenUseCase: RefreshTokenUseCase,
    private val tokenStorage: TokenStorage
) {

    private val mutex = Mutex()
    suspend fun refreshIfNeeded(): String {
        return mutex.withLock {
            val newToken = refreshTokenUseCase.execute(Unit).first()
            tokenStorage.save(newToken)
            newToken.accessToken
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Mutex?
If 5 APIs fail with 401 at the same time:

  • Without Mutex → 5 refresh calls
  • With Mutex → 1 refresh call
  • All suspended callers wait safely.

14.1 Token Refresh Orchestration Pattern (Flow Level)

Now the important part:

  • How do we retry original API after token refresh?

We do NOT handle token inside every ViewModel.
We handle it at repository layer.

Repository-Level Flow Orchestration

Scenario

Fetching portfolio summary.

fun getPortfolioSummary(): Flow<PortfolioSummary> {

   return flow {
        emit(api.getPortfolioSummary())
    }
        .catch { throwable ->
            if (throwable is HttpException && throwable.code() == 401) {
                val newToken = authTokenCoordinator.refreshIfNeeded()
                emit(api.getPortfolioSummaryWithToken(newToken))
            } else {
                throw throwable
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

What Happens Here?

  • API call fails with 401
  • Refresh token (mutex protected)
  • Retry original call
  • Emit result
  • Upstream ViewModel never knows refresh happened

This keeps ViewModel clean.


14.2 Even Cleaner: Reusable Token Wrapper

For large systems, create a reusable wrapper:

fun <T> Flow<T>.withTokenRefresh(
    authTokenCoordinator: AuthTokenCoordinator,
    retryBlock: suspend (String) -> T
): Flow<T> {

    return catch { throwable ->
        if (throwable is HttpException && throwable.code() == 401) {
            val newToken = authTokenCoordinator.refreshIfNeeded()
            emit(retryBlock(newToken))
        } else {
            throw throwable
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

fun getPortfolioSummary(): Flow<PortfolioSummary> {

    return flow {
        emit(api.getPortfolioSummary())
    }.withTokenRefresh(authTokenCoordinator) { newToken ->
        api.getPortfolioSummaryWithToken(newToken)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Pattern Scales

  • Centralized
  • Reusable
  • No duplication
  • Mutex-safe
  • ViewModel unaware of auth complexity
  • Works with combine / zip / flatMap

14.3 What Happens with Parallel Calls + Token Expiry?

Let's say:

initializeDashboard()
  → getInvestmentAccounts()
  → getTotalAssetValue()
Enter fullscreen mode Exit fullscreen mode

Both fail with 401.

What happens?

  • First failure triggers refresh
  • Second waits on mutex
  • Both resume using new token
  • combine still works
  • UI sees only final result

About Auth Orchestration

In this kind of app, Token management is not an interceptor problem only. It is a concurrency orchestration problem.

App must be design with these:

  • Mutex protection
  • Repository-level retry
  • ViewModel isolation
  • Cancellation safety
  • Backoff compatibility

When done correctly:

  • UI remains clean
  • Flows remain declarative
  • Orchestration remains centralized
  • System remains resilient

In fintech apps, concurrency is not optimization.


Final Thought

Coroutines and Flow are not async tools.
They are:

  • Orchestration engine
  • Error propagation model
  • State machine builder
  • Backpressure handler
  • Lifecycle-aware execution framework

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.