๐ŸŸฃ Kotlin Programming

Kotlin Coroutines and Flow Explained for Android Developers

Coroutines are how modern Android apps do anything that takes time without freezing the screen. This guide builds the mental model step by step, from suspend functions to Flow, with diagrams, real Android code and the mistakes that cause leaks and crashes.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 8 min read ๐ŸŽฏ Intermediate
On this page
  1. The problem coroutines solve
  2. Suspend functions: pausing without blocking
  3. Coroutine scopes and structured concurrency
  4. Dispatchers: which thread runs the code
  5. launch vs async, and running work in parallel
  6. Cancellation and exceptions
  7. Flow: streams of values over time
  8. StateFlow and SharedFlow: hot flows
  9. Collecting flows safely in the UI
  10. Testing coroutines and flows
  11. Real-world use cases
  12. Common mistakes and how to fix them
  13. Frequently asked questions
  14. 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.

UserRepository.ktKotlin
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()
    }
}
Blocking call compared with a suspending call on the main thread Top timeline: a blocking network call occupies the main thread, so frames are dropped. Bottom timeline: a coroutine suspends at the network call, the main thread keeps drawing frames, and the coroutine resumes when the response arrives. Blocking call on main thread frames waiting for network: UI frozen frames Coroutine with suspend launch suspended โ†’ main thread keeps drawing frames resume โ†’ update state network work happens elsewhere (OkHttp threads)
Suspension frees the thread. That's why calling a well-written suspend function from the main thread is safe.
Main-safety

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.

ScopeLives as long asUse it for
viewModelScopeThe ViewModel (cancelled in onCleared)Loading screen data, user actions
lifecycleScopeThe Activity/Fragment lifecycleUI-bound work, collecting flows with repeatOnLifecycle
rememberCoroutineScope()The composable in the compositionLaunching from UI callbacks, such as scrolling a list on click
LaunchedEffectThe composable, restarted when its keys changeWork triggered by entering composition or by state changes
Application scope (custom, injected)The processWork that must outlive a screen, such as finishing a write
GlobalScopeForever: no structureAlmost never. Avoid it.
Structured concurrency: cancelling a parent scope cancels its children viewModelScope is the parent of a loadFeed job, which has two child async jobs: fetch posts and fetch stories. When the ViewModel is cleared, cancellation propagates down to all children. viewModelScope launch { loadFeed() } async { fetchPosts() } async { fetchStories() } onCleared() โ†’ cancel โ†“ everything
Leave the screen, the ViewModel is cleared, and both network requests are cancelled. You write no cleanup code.

Dispatchers: which thread runs the code

  • Dispatchers.Main: the Android main thread. Use it for UI and lightweight state updates. viewModelScope and lifecycleScope use Dispatchers.Main.immediate by 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.
ReportRepository.ktKotlin
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()) }
        }
    }
}
Inject dispatchers

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:

FeedViewModel.ktKotlin
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:

  1. Don't swallow CancellationException. A blanket catch (e: Exception) catches it too and can keep a cancelled coroutine running. Catch specific exceptions, or rethrow cancellation.
  2. Make long CPU loops cancellable with ensureActive() or yield(), because they have no suspension points of their own.
  3. Use supervisorScope when one child's failure shouldn't cancel its siblings, for example loading independent dashboard widgets.
Safe error handlingKotlin
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.

Room + FlowKotlin
@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

SearchViewModel.ktKotlin
@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
}
Search query flowing through Flow operators Keystrokes enter a pipeline: debounce, map trim, distinctUntilChanged, flatMapLatest to the repository, then stateIn exposes a StateFlow to the UI. query"ph", "pho"โ€ฆ debounce distinctUntilChanged flatMapLatestโ†’ repository stateIn UI
Each operator does one job. 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.

  • StateFlow always has a current value, replays it to new collectors, and skips emissions equal to the current value. It's ideal for UI state.
  • SharedFlow is configurable (replay, buffer) and has no required initial value. Use it for broadcasting events to multiple collectors.
  • stateIn / shareIn convert 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:

In ComposeKotlin
@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)
}
In a Fragment or ActivityKotlin
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:

MainDispatcherRule.ktKotlin
@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()
}
SearchViewModelTest.ktKotlin
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; a launch refreshes 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
supervisorScope with 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

MistakeSymptomFix
GlobalScope.launchWork continues after leaving the screen; leaksUse viewModelScope or an injected application scope
runBlocking in app codeFrozen UI, ANRsOnly use it in main() or tests
Blocking call without withContextJank, StrictMode violationsWrap it in withContext(Dispatchers.IO)
Catching Exception broadlyCancelled work keeps runningRethrow CancellationException
collect in lifecycleScope without repeatOnLifecycleCollecting in the background; wasted workUse repeatOnLifecycle or collectAsStateWithLifecycle
Exposing MutableStateFlowUI mutates state directlyExpose 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.

See all our apps โ†’

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.

Share: LinkedIn X / Twitter WhatsApp