Skip to content
All posts
June 30, 20263 min read

Kotlin Coroutines: The Patterns I Use in Every App

After using Coroutines across 22+ Android apps, certain patterns appear in every codebase. Here are the ones that matter — with the mistakes they prevent.

KotlinCoroutinesAndroidArchitecture
Share:

Coroutines have a learning curve that flattens out once you understand structured concurrency. After using them across every Android app I've built, the same patterns show up every time. Here they are.

Always use the right scope

Scope determines lifetime. Get this wrong and you get either leaked coroutines or cancelled work.

kotlin
// ViewModel — tied to ViewModel lifecycle
viewModelScope.launch { }

// Fragment/Activity — tied to view lifecycle
viewLifecycleOwner.lifecycleScope.launch { }

// Repository/service — injected via Hilt
class MyRepository @Inject constructor(
    @ApplicationScope private val scope: CoroutineScope
)

Never use

code
GlobalScope
in production. It has no structured relationship to any lifecycle and leaks indefinitely.

Use sealed classes for operation results

The

code
Result<T>
pattern eliminates exception-based control flow and makes error states explicit:

kotlin
sealed class DataResult<out T> {
    data class Success<T>(val data: T) : DataResult<T>()
    data class Error(val exception: Exception) : DataResult<Nothing>()
    object Loading : DataResult<Nothing>()
}

// In repository
suspend fun getUser(id: String): DataResult<User> {
    return try {
        DataResult.Success(api.getUser(id))
    } catch (e: IOException) {
        DataResult.Error(e)
    }
}

// In ViewModel
viewModelScope.launch {
    _uiState.update { it.copy(isLoading = true) }
    when (val result = repository.getUser(userId)) {
        is DataResult.Success -> _uiState.update { it.copy(user = result.data, isLoading = false) }
        is DataResult.Error -> _uiState.update { it.copy(error = result.exception.message, isLoading = false) }
        DataResult.Loading -> Unit
    }
}

Use withContext for dispatcher switching

Don't launch new coroutines to switch dispatchers — use

code
withContext
:

kotlin
// Wrong — creates unnecessary coroutine
viewModelScope.launch {
    launch(Dispatchers.IO) {
        val data = repository.fetch()
        withContext(Dispatchers.Main) {
            updateUI(data)
        }
    }
}

// Right — stays in one coroutine
viewModelScope.launch {
    val data = withContext(Dispatchers.IO) { repository.fetch() }
    updateUI(data) // already on Main via viewModelScope
}

code
viewModelScope
runs on
code
Dispatchers.Main.immediate
by default. Switch to
code
IO
only where you need it, then return to the caller's context automatically.

Combine multiple flows with combine

When UI state depends on multiple sources:

kotlin
val uiState: StateFlow<HomeUiState> = combine(
    userRepository.currentUser,
    notificationsRepository.unreadCount,
    settingsRepository.theme
) { user, count, theme ->
    HomeUiState(user = user, unreadCount = count, theme = theme)
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = HomeUiState()
)

code
stateIn
converts a cold Flow to a hot StateFlow.
code
WhileSubscribed(5000)
keeps it active for 5 seconds after the last subscriber leaves — handles configuration changes without restarting upstream.

Handle cancellation explicitly for cleanup

When a coroutine is cancelled,

code
CancellationException
is thrown. Don't catch it:

kotlin
// Wrong — swallows cancellation
try {
    longRunningOperation()
} catch (e: Exception) {
    handleError(e) // this catches CancellationException too
}

// Right
try {
    longRunningOperation()
} catch (e: CancellationException) {
    throw e // re-throw, let structured concurrency handle it
} catch (e: Exception) {
    handleError(e)
}

For cleanup on cancellation, use

code
try/finally
:

kotlin
try {
    performWork()
} finally {
    cleanup() // always runs, even on cancellation
}

Use supervisorScope for parallel independent work

code
coroutineScope
fails fast — if one child fails, all are cancelled.
code
supervisorScope
lets failures be handled independently:

kotlin
supervisorScope {
    val profileDeferred = async { repository.getProfile() }
    val feedDeferred = async { repository.getFeed() }

    val profile = try { profileDeferred.await() } catch (e: Exception) { null }
    val feed = try { feedDeferred.await() } catch (e: Exception) { emptyList() }

    // Show what we have, even if one request failed
    updateUI(profile, feed)
}

Use

code
supervisorScope
when parallel operations are independent and partial failure is acceptable. Use
code
coroutineScope
when all or nothing is required.

The pattern for testing

Use

code
TestCoroutineDispatcher
or
code
StandardTestDispatcher
with
code
runTest
:

kotlin
@Test
fun `login success updates ui state`() = runTest {
    val viewModel = LoginViewModel(fakeRepository)
    viewModel.onLoginClicked("user@example.com", "password")
    advanceUntilIdle()
    assertEquals(LoginUiState.Success, viewModel.uiState.value)
}

code
advanceUntilIdle()
runs all pending coroutines to completion synchronously. No
code
Thread.sleep
, no flakiness.

These patterns cover 90% of what you need. The rest is context-specific, but gets significantly easier once these are second nature.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this