On this page
- The problem coroutines solve
- Suspend functions: pausing without blocking
- Coroutine scopes and structured concurrency
- Dispatchers: which thread runs the code
- launch vs async, and running work in parallel
- Cancellation and exceptions
- Flow: streams of values over time
- StateFlow and SharedFlow: hot flows
- Collecting flows safely in the UI
- Testing coroutines and flows
- Real-world use cases
- Common mistakes and how to fix them
- Frequently asked questions
- Conclusion
Every Android app waits for things: network responses, database queries, file reads, location updates. Do that waiting on the main thread and the UI freezes. Do it with callbacks and the code turns into nested, hard-to-cancel spaghetti. Kotlin coroutines solve both problems, and Flow extends them to values that change over time.
This guide is for developers who know Kotlin basics and have seen viewModelScope.launch without fully understanding what happens inside it. By the end, you'll know how suspension works, what scopes and dispatchers are really for, how cancellation protects you from leaks, how cold and hot flows differ, and how to test all of it.
The problem coroutines solve
Android draws the UI on the main thread. To stay smooth it must finish each frame in roughly 16 ms at 60 Hz, and less on high-refresh screens. A network call can take seconds. Block the main thread that long and the app stutters. Block it for about five seconds and Android shows an "Application Not Responding" dialog.
The traditional fix, background threads with callbacks, works but creates three problems: callback nesting, manual thread switching back to the UI, and no automatic cancellation when the user leaves the screen. Coroutines address all three.
Suspend functions: pausing without blocking
A suspend function can pause its execution and resume later, without blocking the thread it runs on. While it's suspended, waiting for a network response for example, the thread is free to do other work, such as drawing frames.
class UserRepository(
private val api: UserApi, // Retrofit interface with suspend functions
private val dao: UserDao, // Room DAO with suspend functions
) {
// Reads top to bottom like synchronous code, but never blocks the caller's thread.
suspend fun refreshUser(id: String): User {
val remote = api.getUser(id) // suspends while waiting for the network
dao.upsert(remote.toEntity()) // suspends while Room writes on its own executor
return remote.toDomain()
}
}A suspend function is main-safe when it can be called from the main thread without blocking it. Retrofit and Room suspend functions are main-safe because they move their work to background threads internally. Your own blocking code, such as file I/O or heavy JSON parsing, isn't main-safe until you wrap it in withContext(Dispatchers.IO) or Dispatchers.Default.
Coroutine scopes and structured concurrency
Every coroutine runs inside a scope, and every scope has a lifetime. When the scope is cancelled, all coroutines inside it are cancelled too. This is structured concurrency, and it's the single most important idea for avoiding leaks.
| Scope | Lives as long as | Use it for |
|---|---|---|
viewModelScope | The ViewModel (cancelled in onCleared) | Loading screen data, user actions |
lifecycleScope | The Activity/Fragment lifecycle | UI-bound work, collecting flows with repeatOnLifecycle |
rememberCoroutineScope() | The composable in the composition | Launching from UI callbacks, such as scrolling a list on click |
LaunchedEffect | The composable, restarted when its keys change | Work triggered by entering composition or by state changes |
| Application scope (custom, injected) | The process | Work that must outlive a screen, such as finishing a write |
GlobalScope | Forever: no structure | Almost never. Avoid it. |
Dispatchers: which thread runs the code
Dispatchers.Main: the Android main thread. Use it for UI and lightweight state updates.viewModelScopeandlifecycleScopeuseDispatchers.Main.immediateby default.Dispatchers.IO: a thread pool for blocking I/O such as files, legacy blocking SDKs and raw sockets.Dispatchers.Default: CPU-bound work such as sorting large lists, parsing big JSON or image processing.
class ReportRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, // injectable for tests
) {
suspend fun exportCsv(file: File, rows: List<Row>) = withContext(ioDispatcher) {
// Blocking file I/O is safe here; callers can stay on Main.
file.bufferedWriter().use { writer ->
rows.forEach { writer.appendLine(it.toCsvLine()) }
}
}
}Pass dispatchers in through the constructor instead of hard-coding Dispatchers.IO. In tests you can then swap in a test dispatcher and control time precisely.
launch vs async, and running work in parallel
launch starts a coroutine and returns a Job ("fire and forget, but cancellable"). async returns a Deferred<T> whose result you await(). Use async inside coroutineScope to run independent requests in parallel:
fun loadFeed() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
// Both requests start immediately; total time โ the slower one, not the sum.
val feed = coroutineScope {
val posts = async { repository.getPosts() }
val stories = async { repository.getStories() }
Feed(posts.await(), stories.await())
}
_uiState.update { it.copy(isLoading = false, feed = feed) }
} catch (e: IOException) {
// If either request fails, coroutineScope cancels the other one too.
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}Cancellation and exceptions
Cancellation in coroutines is cooperative. A coroutine is cancelled at its next suspension point, where it receives a CancellationException. Three rules prevent most bugs:
- Don't swallow
CancellationException. A blanketcatch (e: Exception)catches it too and can keep a cancelled coroutine running. Catch specific exceptions, or rethrow cancellation. - Make long CPU loops cancellable with
ensureActive()oryield(), because they have no suspension points of their own. - Use
supervisorScopewhen one child's failure shouldn't cancel its siblings, for example loading independent dashboard widgets.
viewModelScope.launch {
try {
val result = repository.sync()
_uiState.update { it.copy(lastSync = result.time) }
} catch (e: CancellationException) {
throw e // let cancellation propagate
} catch (e: Exception) {
_uiState.update { it.copy(error = "Sync failed") }
}
}Flow: streams of values over time
A suspend function returns one value. A Flow<T> emits many values over time, such as database rows that change, search results as the user types, or location updates. Flows are cold by default: the code inside flow { } doesn't run until someone collects it, and it runs again for each collector.
@Dao
interface NoteDao {
// Room re-emits automatically whenever the notes table changes.
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeNotes(): Flow<List<NoteEntity>>
}Operators: transforming flows
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: ProductRepository) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<SearchUiState> = query
.debounce(300) // wait for typing to pause
.map { it.trim() }
.distinctUntilChanged() // ignore identical queries
.flatMapLatest { q -> // cancel the previous search when a new query arrives
if (q.isEmpty()) flowOf(SearchUiState.Idle)
else flow {
emit(SearchUiState.Loading)
emit(SearchUiState.Success(repository.search(q)))
}.catch { emit(SearchUiState.Error("Search failed")) }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // keep alive briefly across rotation
initialValue = SearchUiState.Idle,
)
fun onQueryChange(text: String) { query.value = text }
}
sealed interface SearchUiState {
data object Idle : SearchUiState
data object Loading : SearchUiState
data class Success(val products: List<Product>) : SearchUiState
data class Error(val message: String) : SearchUiState
}flatMapLatest is what prevents an old, slow search from overwriting newer results.StateFlow and SharedFlow: hot flows
Hot flows exist independently of collectors and share emissions among them.
StateFlowalways has a current value, replays it to new collectors, and skips emissions equal to the current value. It's ideal for UI state.SharedFlowis configurable (replay, buffer) and has no required initial value. Use it for broadcasting events to multiple collectors.stateIn/shareInconvert a cold flow into a hot one, so expensive upstream work such as a database query runs once and is shared.
The differences deserve their own article: see MVVM vs MVI for how state and one-off events are exposed in practice.
Collecting flows safely in the UI
Collecting a flow while the app is in the background wastes resources, and can crash if you touch Views that no longer exist. Use lifecycle-aware collection:
@Composable
fun SearchRoute(viewModel: SearchViewModel = viewModel()) {
// Stops collecting when the lifecycle drops below STARTED, resumes when it returns.
val state by viewModel.results.collectAsStateWithLifecycle()
SearchScreen(state = state, onQueryChange = viewModel::onQueryChange)
}viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.results.collect { state -> render(state) }
}
}Testing coroutines and flows
The kotlinx-coroutines-test library provides runTest, which skips delay() calls in virtual time, so a test with a 300 ms debounce runs instantly. Replace Dispatchers.Main in tests, because there is no Android main looper on the JVM:
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}class SearchViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
private val fakeRepo = object : ProductRepository {
override suspend fun search(query: String) = listOf(Product("1", "Phone case", 299.0))
}
@Test
fun `typing a query emits success after debounce`() = runTest {
val vm = SearchViewModel(fakeRepo)
val states = mutableListOf<SearchUiState>()
// Collect in the background so stateIn's WhileSubscribed upstream is active.
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.results.toList(states) }
vm.onQueryChange("case")
advanceTimeBy(301) // move virtual time past the debounce
runCurrent()
assertTrue(states.last() is SearchUiState.Success)
}
}Real-world use cases
Offline-first list
- Problem
- Show cached data instantly, refresh in the background.
- Solution
- UI observes Room's
Flow; alaunchrefreshes from the network and writes to Room. - Why
- The database is the single source of truth, and the UI updates automatically.
- Trade-off
- You need a clear staleness and refresh policy.
Search-as-you-type
- Problem
- Avoid a request per keystroke and stale results.
- Solution
debounce+distinctUntilChanged+flatMapLatest.- Why
- Fewer requests, automatic cancellation of outdated ones.
- Trade-off
- Debounce adds a small, deliberate delay.
Dashboard with independent widgets
- Problem
- One failing widget shouldn't blank the whole screen.
- Solution
supervisorScopewith per-widget error handling.- Why
- Failures are isolated to each widget.
- Trade-off
- Each widget needs its own error UI.
Common mistakes and how to fix them
| Mistake | Symptom | Fix |
|---|---|---|
GlobalScope.launch | Work continues after leaving the screen; leaks | Use viewModelScope or an injected application scope |
runBlocking in app code | Frozen UI, ANRs | Only use it in main() or tests |
Blocking call without withContext | Jank, StrictMode violations | Wrap it in withContext(Dispatchers.IO) |
Catching Exception broadly | Cancelled work keeps running | Rethrow CancellationException |
collect in lifecycleScope without repeatOnLifecycle | Collecting in the background; wasted work | Use repeatOnLifecycle or collectAsStateWithLifecycle |
Exposing MutableStateFlow | UI mutates state directly | Expose StateFlow via asStateFlow() |
Frequently asked questions
Are coroutines threads?
No. Coroutines are lightweight units of work that run on threads. Thousands of suspended coroutines can share a small thread pool, because a suspended coroutine doesn't occupy a thread.
Do I still need Dispatchers.IO with Retrofit and Room?
Not for their suspend functions, which are already main-safe. You need it for your own blocking code, such as file access or blocking SDK calls.
When should I use Flow instead of a suspend function?
Use a suspend function for a single result, such as fetching a user. Use Flow when values change over time, such as database observation, location updates or user input streams.
Should I replace RxJava with coroutines?
For new code in Kotlin, coroutines and Flow are the standard choice and integrate with Jetpack. Migrate existing RxJava code gradually. Interop libraries exist to bridge the two.
What does WhileSubscribed(5000) do?
It keeps the upstream flow active for five seconds after the last collector disappears. That's long enough to survive a configuration change such as rotation without restarting expensive work, but it still stops when the user truly leaves.
Conclusion
Coroutines give Android a simple promise: write asynchronous code sequentially, and let scopes clean up after you. Learn suspension and main-safety first, then structured concurrency and dispatchers, then Flow and its operators. Test with runTest and injected dispatchers, and avoid GlobalScope and swallowed cancellations.
Next step: take one callback-based API call in a project and rewrite it as a suspend function called from viewModelScope. Then add a Room Flow to the same screen. To see where this code lives in a well-structured app, continue with Clean Architecture in Android.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Kotlin fluency makes everything else in Android easier, from coroutines and Flow to Compose and clean architecture. The fastest way to build that fluency is to write real Kotlin every day and get feedback on it.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.