Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 011 / 100

Kotlin coroutines tutorial: patterns, pitfalls and fixes

Write async code that reads like sync code.

Expona Admin6 min read

Why coroutines matter for modern Android

Building responsive Android apps means handling network requests, database queries, and file I/O without freezing the UI thread. The traditional callback-based approach leads to deeply nested code and cancellation nightmares. A Kotlin coroutines tutorial: patterns, pitfalls and fixes gives you the mental model and practical patterns to write async code that reads like sync code, making your codebase cleaner and your team more productive.

In this guide, we'll walk through how suspend functions work, how to launch coroutines safely in a ViewModel scope, when to pick the right dispatcher, and how structured concurrency prevents resource leaks.

Suspend functions: pausing without blocking

A suspend function is the foundation of coroutines. Unlike a regular function that either runs to completion or throws, a suspend function can pause execution and resume later—without blocking the thread. The magic keyword is suspend.

kotlin
suspend fun loadUser(id: String): User {
    return withContext(Dispatchers.IO) {
        api.getUser(id)  // Network call
    }
}

When you call loadUser, the coroutine suspends at the network call. The thread that was running the coroutine is freed up to handle other work. Once the network call completes, the coroutine resumes and returns the result.

You cannot call a suspend function from a regular (non-suspend) function. The compiler enforces this. So if you're in your Activity or Fragment, you need a coroutine scope to call suspend functions. That's where launch comes in.

Think of suspend functions as the building blocks. They're typically defined in repositories, data layers, or use-case classes. They handle the actual async work. Your ViewModel then orchestrates calling them.

Launch work with viewModelScope

The viewModelScope is a pre-built coroutine scope provided by AndroidX. It's tied to the ViewModel lifecycle, so coroutines launched in it are automatically cancelled when the ViewModel is cleared. This prevents memory leaks and orphaned network requests.

kotlin
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {
    private val _state = MutableLiveData<UiState>()
    val state: LiveData<UiState> = _state

    fun loadUser(id: String) {
        viewModelScope.launch {
            _state.value = UiState.Loading
            try {
                val user = repository.loadUser(id)
                _state.value = UiState.Success(user)
            } catch (e: Exception) {
                _state.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

In this snippet, launch starts a new coroutine. The code inside the lambda reads sequentially: load the user, update the state. Behind the scenes, the network call is async, but from the reader's perspective, it's straightforward.

If the ViewModel is destroyed (user navigates away), viewModelScope cancels the coroutine. Any pending network request is cancelled, and _state.value = UiState.Success(user) won't execute. This is safe and prevents crashes from trying to update a dead ViewModel.

For work that must survive ViewModel destruction, use GlobalScope sparingly and with caution—it has no automatic cancellation and can leak resources.

Dispatchers: choosing the right thread pool

By default, launch uses Dispatchers.Main, which is correct for UI updates. But not all work should run on the main thread. Dispatchers control which thread (or thread pool) executes your coroutine.

Dispatchers.Main: Use for UI updates. This runs on the main/UI thread. Never block it with heavy computation or I/O.

Dispatchers.IO: Use for network calls, database queries, file reads/writes. It has a thread pool tuned for I/O-bound work (up to 64 threads by default). Multiple coroutines can efficiently share threads while waiting for I/O.

Dispatchers.Default: Use for CPU-intensive work like parsing large JSON, heavy calculations, or sorting. It has a thread pool sized to the number of CPU cores.

kotlin
class UserRepository(private val api: UserApi, private val db: UserDao) {
    
    suspend fun loadUser(id: String): User {
        return withContext(Dispatchers.IO) {
            // Try local database first
            val cached = db.getUser(id)
            if (cached != null) return@withContext cached
            
            // Fetch from network
            val user = api.getUser(id)
            db.insert(user)  // Save to database
            user
        }
    }

    suspend fun parseUserList(jsonString: String): List<User> {
        return withContext(Dispatchers.Default) {
            // CPU-intensive parsing
            gson.fromJson(jsonString, Array<User>::class.java).toList()
        }
    }
}

The withContext function temporarily switches to a different dispatcher, executes the block, and switches back. It's the primary way to respect dispatcher boundaries in your suspend functions.

In the ViewModel example earlier, we didn't specify a dispatcher in launch, so it used Dispatchers.Main. But the repository's loadUser internally calls withContext(Dispatchers.IO), which safely offloads the network call. After the network call completes, we return to Dispatchers.Main` so the state update happens on the UI thread.

Structured concurrency: automatic cancellation

Structured concurrency is the principle that every coroutine has a parent scope, and child coroutines are cancelled when the parent is cancelled. This prevents orphaned background tasks.

When you use viewModelScope.launch, your coroutine is a child of the ViewModel's scope. If you launch multiple coroutines within the same call, they're all siblings under the same parent:

kotlin
fun loadUserWithPosts(userId: String) {
    viewModelScope.launch {
        try {
            // Both calls run concurrently
            val userDeferred = async { repository.loadUser(userId) }
            val postsDeferred = async { repository.loadPosts(userId) }
            
            val user = userDeferred.await()
            val posts = postsDeferred.await()
            
            _state.value = UiState.Success(user, posts)
        } catch (e: Exception) {
            _state.value = UiState.Error(e.message)
        }
    }
}

Here, async launches a child coroutine that returns a deferred result. If an exception occurs in either async` block, the entire launch fails, and both child coroutines are cancelled. You don't manually cancel anything.

If the user navigates away before both network calls finish, viewModelScope is cancelled, which automatically cancels both async calls. No dangling requests, no leaks.

This is structured concurrency in action: the scope hierarchy ensures every coroutine has a lifecycle and cleanup happens automatically.

Common mistakes and how to fix them

Launching in GlobalScope: This bypasses structured concurrency. The coroutine runs until it finishes or the app dies. If the user navigates away, the coroutine keeps running, wasting resources.

Fix: Always use viewModelScope, lifecycleScope, or a custom scope tied to a lifecycle.

Fire-and-forget without error handling: If you launch without a try-catch, an exception crashes the coroutine silently. The state never updates, and the UI looks frozen.

Fix: Wrap suspend calls in try-catch or use coroutineExceptionHandler for centralized error handling.

Blocking suspend functions: Using Thread.sleep() or runBlocking inside a suspend function defeats the purpose. You're blocking the thread, reducing concurrency.

Fix: Use delay() instead, which is non-blocking and suspends the coroutine.

Mixing dispatchers incorrectly: Running a database query on Dispatchers.Main will block the UI. Running UI updates on Dispatchers.IO will crash (only the main thread can update the UI).

Fix: Use withContext to switch dispatchers. Keep UI updates on Main, I/O work on IO, CPU work on Default.

Key takeaways

  • Suspend functions pause without blocking threads, making them ideal for async operations like network requests.
  • Launch coroutines using viewModelScope to bind them to the ViewModel lifecycle and ensure automatic cancellation.
  • Choose the right dispatcher: Dispatchers.Main for UI, Dispatchers.IO for I/O, Dispatchers.Default for CPU work.
  • Structured concurrency automatically cancels child coroutines when the parent scope is cancelled, preventing resource leaks.
  • Use try-catch within coroutines to handle exceptions gracefully and update UI state even on failure.
  • Avoid GlobalScope and blocking calls like Thread.sleep() inside suspend functions.
  • Always use withContext to switch dispatchers in suspend functions, preserving thread safety and responsiveness.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles