Skip to main content

Command Palette

Search for a command to run...

Kotlin Coroutines Demystified

Updated
5 min readView as Markdown

The Complete Practical Guide Every Kotlin Developer Should Read

When I first started using Coroutines, I thought I understood them.

I could write:

viewModelScope.launch {
    val users = repository.getUsers()
    updateUi(users)
}

The code worked. The UI was smooth.

Then a production bug appeared. A cancelled screen was still making network calls and a loading spinner never stopped.

That was the moment I realized:

Knowing coroutine syntax is not the same as understanding coroutines.

Coroutines are one of the most powerful features in Kotlin, but they are also one of the most misunderstood. This guide covers the fundamentals, internals, best practices, pitfalls, and real-world usage.


Why Coroutines Exist

Before Coroutines, asynchronous programming often involved:

  • Threads
  • Executors
  • Futures
  • Callbacks
  • CompletableFutures

As projects grew, callback chains became difficult to maintain.

Coroutines were introduced to provide asynchronous programming with code that reads like synchronous code.


What Is a Coroutine?

A common definition is:

Coroutines are lightweight threads.

A more accurate definition is:

A coroutine is a unit of work that can suspend and resume without blocking a thread.

Example:

suspend fun fetchUser() {
    delay(1000)
}

delay() suspends the coroutine and frees the thread for other work.


How Coroutines Work Under the Hood

The Kotlin compiler converts suspend functions into state machines.

When suspension occurs:

  1. Current state is saved.
  2. Execution pauses.
  3. Thread becomes available.
  4. Coroutine resumes later from the saved state.

This is the primary reason coroutines scale efficiently.


suspend Does Not Start a Coroutine

suspend fun getUser()

A suspend function only indicates that suspension may occur.

Coroutines are started using builders such as:

launch {}
async {}
runBlocking {}

launch vs async

launch

Use when no result is required.

launch {
    syncUsers()
}

Returns:

Job

async

Use when a result is needed.

val user = async {
    getUser()
}

user.await()

Returns:

Deferred<T>

Parallel Execution Example

coroutineScope {
    val user = async { getUser() }
    val posts = async { getPosts() }

    user.await()
    posts.await()
}

A common use case is loading profile data and dashboard data together.


CoroutineContext

Every coroutine contains context information.

launch(
    Dispatchers.IO +
    CoroutineName("UserSync")
) {
}

Typically includes:

  • Dispatcher
  • Job
  • CoroutineName
  • CoroutineExceptionHandler

Dispatchers Explained

Dispatchers.Main

For UI updates.

withContext(Dispatchers.Main) {
    renderUi()
}

Dispatchers.IO

For network, file and database operations.

withContext(Dispatchers.IO) {
    api.getUsers()
}

Dispatchers.Default

For CPU-intensive operations.

withContext(Dispatchers.Default) {
    calculateReport()
}

Tricky Point

Don't run CPU-heavy work on IO.

Rule:

Waiting -> IO
Computing -> Default

Structured Concurrency

One of the biggest advantages of coroutines.

coroutineScope {
    launch { fetchUsers() }
    launch { fetchOrders() }
}

Benefits:

  • Children belong to a parent scope.
  • Parent waits for children.
  • Failures propagate predictably.
  • Reduces leaks.

GlobalScope: A Common Mistake

Avoid:

GlobalScope.launch {
    syncUsers()
}

Problems:

  • No lifecycle awareness
  • Hard cancellation
  • Debugging issues
  • Memory leak risk

Use:

viewModelScope
lifecycleScope

Cancellation Is Cooperative

Bad:

while(true) {
}

Good:

while(isActive) {
}

Another good practice:

ensureActive()

Blocking vs Suspending

Bad:

Thread.sleep(5000)

Good:

delay(5000)

Thread.sleep() blocks threads.

delay() suspends coroutines.


Exception Handling

val handler = CoroutineExceptionHandler { _, throwable ->
    println(throwable.message)
}

Use it for top-level coroutine exception handling.


SupervisorJob

Scenario:

  • Load profile
  • Load notifications
  • Load messages

If notifications fail, profile should still load.

val scope = CoroutineScope(
    SupervisorJob() + Dispatchers.IO
)

This isolates failures.


Flow

Flow is designed for streams of values.

flow {
    emit(1)
    emit(2)
    emit(3)
}

Collect values:

flow.collect {
    println(it)
}

StateFlow vs SharedFlow

StateFlow

Best for UI state.

MutableStateFlow(UiState())

Examples:

  • Loading
  • Success
  • Error

SharedFlow

Best for one-time events.

MutableSharedFlow<Event>()

Examples:

  • Toast
  • Navigation
  • Dialog events

Tricky Point

Avoid using StateFlow for navigation events because they can be replayed after configuration changes.


Useful Flow Operators

map

flow.map { it.name }

filter

flow.filter { it.isActive }

debounce

searchQuery.debounce(500)

Useful for search APIs.

combine

combine(userFlow, settingsFlow) { user, settings ->
    Ui(user, settings)
}

Flow vs Channel

Flow

Best for stream processing.

Flow<User>

Channel

Best for producer-consumer communication.

Channel<Task>()

Most Android use cases need Flow more often than Channel.


Race Conditions Still Exist

Bad:

var count = 0

repeat(1000) {
    launch {
        count++
    }
}

Use:

Mutex
AtomicInteger

for shared mutable state.


Quick Decision Guide

Requirement Recommended Tool
Background task launch
Need a result async
Thread switch withContext
UI state StateFlow
One-time event SharedFlow
Data stream Flow
Parallel execution async + await
Failure isolation SupervisorJob
Android lifecycle work viewModelScope

Final Thoughts

Coroutines are not just about writing asynchronous code.

They're about writing asynchronous code that remains readable, maintainable, efficient, and safe.

Once you understand:

  • Suspension vs Blocking
  • Dispatchers
  • CoroutineContext
  • Structured Concurrency
  • Cancellation
  • Exception Propagation
  • Flow APIs
  • StateFlow vs SharedFlow

Coroutines stop feeling magical and start feeling predictable.

And predictable systems are what great software is built on.

Happy Coding! 🚀