If you've ever written Android code that fetches data from the internet, you've faced this problem: doing that work on the main thread freezes the app. The old solutions — callbacks, threads, AsyncTask — all work but they make your code messy and hard to follow.

Kotlin Coroutines solve this problem elegantly. This guide explains everything from scratch with real examples so you can start using coroutines confidently.


The Problem Coroutines Solve

Imagine you're ordering food at a restaurant. The waiter takes your order, goes to the kitchen, and stands there staring at the chef until your food is ready — not serving anyone else. That's a blocking operation.

In Android, the main thread is that waiter. If it gets stuck waiting for a network response, nothing else can happen — buttons stop responding, animations freeze, and the user sees an ANR (App Not Responding) crash.

The old ways of solving this:

  • Callbacks — work but create deeply nested "callback hell" that's hard to read
  • Threads — work but are expensive, hard to manage, and easy to leak
  • AsyncTask — Android-specific, deprecated, caused memory leaks and lifecycle problems

Coroutines give you a clean, simple way to do background work that reads almost like normal sequential code.

// Old way with callbacks — messy
fun loadUser(userId: String) {
    api.getUser(userId, onSuccess = { user ->
        db.saveUser(user, onSuccess = {
            notifyUI(onSuccess = {
                // deeply nested, hard to follow
            })
        })
    })
}

// Coroutine way — clean and readable
suspend fun loadUser(userId: String) {
    val user = api.getUser(userId)   // waits, but doesn't block
    db.saveUser(user)                // then this
    notifyUI()                       // then this
}

Same logic, but the coroutine version reads top to bottom like normal code.


What Is a Coroutine?

A coroutine is a piece of code that can pause itself (suspend) without blocking the thread it's running on, and resume later when its work is ready to continue.

Real-world analogy: Imagine a chef who puts a dish in the oven, sets a timer, and goes to prep another dish while waiting. When the timer goes off, they come back to the oven. They never stood there staring — they suspended their work on dish 1, did other things, then resumed. That's exactly how a coroutine works.


Setting Up Coroutines

Add this to your build.gradle:

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
}

suspend Functions — The Building Block

Before understanding coroutines fully, you need to understand suspend functions. A suspend function is a function that can be paused and resumed. It can only be called from inside a coroutine or another suspend function.

// Regular function — runs and returns immediately
fun getName(): String {
    return "John"
}

// Suspend function — can pause while waiting
suspend fun getUserFromServer(): String {
    delay(1000)          // pauses for 1 second (doesn't block thread)
    return "John"        // then resumes and returns
}

delay() is itself a suspend function — it pauses the coroutine without blocking the thread, unlike Thread.sleep() which blocks the thread completely.

// Thread.sleep — blocks the thread, nothing else can run
Thread.sleep(1000)

// delay — suspends the coroutine, thread is free to do other work
delay(1000)

Coroutine Builders — How to Start a Coroutine

launch — Fire and Forget

Use launch when you want to start a coroutine but don't need a result back. It returns a Job you can use to track or cancel the work.

Analogy: You send an email and don't wait for a reply — you just continue with your day.

viewModelScope.launch {
    val user = userRepository.getUser()
    _userName.value = user.name
}

Practical Android example:

class HomeViewModel : ViewModel() {

    private val _posts = MutableLiveData<List<Post>>()
    val posts: LiveData<List<Post>> = _posts

    fun loadPosts() {
        viewModelScope.launch {
            try {
                val result = withContext(Dispatchers.IO) {
                    api.getPosts()           // network call on background thread
                }
                _posts.value = result        // update LiveData on main thread
            } catch (e: Exception) {
                // handle error
            }
        }
    }
}

async — Launch and Get a Result

Use async when you want to run a coroutine and get a result back. It returns a Deferred — like a promise that will hold the result.

Analogy: You order something online. You get a tracking number (Deferred). You can check back later to see if it arrived. When you call .await(), you're checking if the package has arrived — and if not, you wait.

viewModelScope.launch {
    // Start both requests at the SAME TIME
    val userDeferred = async(Dispatchers.IO) { api.getUser() }
    val postsDeferred = async(Dispatchers.IO) { api.getPosts() }

    // Wait for BOTH to finish
    val user = userDeferred.await()
    val posts = postsDeferred.await()

    // Both are done — update UI
    showProfile(user, posts)
}

This is powerful — both network calls run in parallel. If each takes 1 second, the total time is 1 second, not 2.

launch vs async — when to use which:

// launch — you don't need the result
viewModelScope.launch {
    analytics.logEvent("screen_viewed")  // fire and forget
}

// async — you need the result
viewModelScope.launch {
    val token = async { authService.getToken() }
    val profile = async { profileService.getProfile() }
    setupDashboard(token.await(), profile.await())
}

withContext — Switch Thread, Get Result

withContext runs a block of code on a different dispatcher and returns the result. Unlike async, it runs sequentially (not in parallel).

viewModelScope.launch {
    // Currently on Main thread
    showLoading(true)

    val data = withContext(Dispatchers.IO) {
        database.getAllPosts()    // runs on IO thread, returns result
    }

    // Back on Main thread
    showLoading(false)
    adapter.submitList(data)
}

Think of withContext as: "switch to this thread, do this work, come back with the result."


Coroutine Scopes — Where Coroutines Live

A coroutine scope defines the lifetime of coroutines. When a scope is cancelled, all coroutines inside it are cancelled too. This prevents memory leaks and wasted background work.

viewModelScope — For ViewModels

The most common scope in Android. Automatically cancelled when the ViewModel is destroyed.

class ArticleViewModel : ViewModel() {

    fun loadArticle(id: Int) {
        viewModelScope.launch {
            val article = withContext(Dispatchers.IO) {
                repository.getArticle(id)
            }
            _article.value = article
        }
    }

    // When ViewModel is cleared, all coroutines cancel automatically
    // No manual cleanup needed!
}

lifecycleScope — For Activities and Fragments

Automatically cancelled when the Activity or Fragment is destroyed.

class HomeFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                updateUI(state)
            }
        }
    }
}

coroutineScope — For Grouping Work

Creates a temporary scope that waits for all its children to finish before returning.

suspend fun loadDashboard() = coroutineScope {
    // Both run in parallel, function doesn't return until BOTH finish
    val stats = async { repository.getStats() }
    val notifications = async { repository.getNotifications() }

    DashboardData(
        stats = stats.await(),
        notifications = notifications.await()
    )
}

supervisorScope — Independent Failures

In a normal coroutineScope, if one child fails, all others are cancelled. supervisorScope prevents this — each child is independent.

supervisorScope {
    launch {
        // Even if this fails...
        val ads = adService.loadAds()
        showAds(ads)
    }

    launch {
        // ...this still runs normally
        val articles = repository.getArticles()
        showArticles(articles)
    }
}

GlobalScope — Avoid in Most Cases

Lives for the entire app lifetime. Coroutines launched here are never automatically cancelled — this causes memory leaks.

// Avoid GlobalScope in most cases
GlobalScope.launch {
    // runs forever, even if the user left the screen
}

// Use viewModelScope or lifecycleScope instead
viewModelScope.launch {
    // automatically cancelled when ViewModel is destroyed
}

Managing Jobs — Control Your Coroutines

Every launch returns a Job. You can use it to check status, wait, or cancel.

val job = viewModelScope.launch {
    repeat(10) { i ->
        println("Working: $i")
        delay(500)
    }
}

println(job.isActive)     // true while running
println(job.isCompleted)  // true when done
println(job.isCancelled)  // true if cancelled

job.cancel()              // cancel it
job.cancelAndJoin()       // cancel and wait for cleanup to finish

Real use case — cancel previous search when user types a new query:

class SearchViewModel : ViewModel() {

    private var searchJob: Job? = null

    fun search(query: String) {
        searchJob?.cancel()  // cancel previous search

        searchJob = viewModelScope.launch {
            delay(300)  // debounce — wait before searching
            val results = withContext(Dispatchers.IO) {
                searchRepository.search(query)
            }
            _results.value = results
        }
    }
}

Handling Errors in Coroutines

try-catch

viewModelScope.launch {
    try {
        val data = withContext(Dispatchers.IO) {
            api.fetchData()
        }
        _data.value = data
    } catch (e: IOException) {
        _error.value = "No internet connection"
    } catch (e: HttpException) {
        _error.value = "Server error: ${e.code()}"
    }
}

Always Re-throw CancellationException

When a coroutine is cancelled, a CancellationException is thrown. Never swallow it — always re-throw it so the cancellation propagates correctly:

viewModelScope.launch {
    try {
        doLongWork()
    } catch (e: CancellationException) {
        throw e          // always re-throw this
    } catch (e: Exception) {
        handleError(e)   // handle real errors here
    } finally {
        cleanUp()        // always runs — success, error, or cancel
    }
}

Real-World Example — Full ViewModel

Here's a complete, realistic ViewModel that loads a user profile and posts simultaneously:

class ProfileViewModel(
    private val userRepository: UserRepository,
    private val postRepository: PostRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
    val uiState: StateFlow<ProfileUiState> = _uiState

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading

            try {
                // Load user and posts IN PARALLEL
                val userDeferred = async(Dispatchers.IO) {
                    userRepository.getUser(userId)
                }
                val postsDeferred = async(Dispatchers.IO) {
                    postRepository.getUserPosts(userId)
                }

                val user = userDeferred.await()
                val posts = postsDeferred.await()

                _uiState.value = ProfileUiState.Success(user, posts)

            } catch (e: Exception) {
                _uiState.value = ProfileUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

sealed class ProfileUiState {
    object Loading : ProfileUiState()
    data class Success(val user: User, val posts: List<Post>) : ProfileUiState()
    data class Error(val message: String) : ProfileUiState()
}

Quick Reference

Builder Returns Use When
launch Job Fire and forget, no result needed
async Deferred<T> Need a result, run tasks in parallel
withContext T Switch thread, get result sequentially
Scope Lifetime Use In
viewModelScope Until ViewModel cleared ViewModel
lifecycleScope Until Activity/Fragment destroyed Activity, Fragment
coroutineScope Until all children finish suspend functions
supervisorScope Until all children finish (independent) Parallel independent tasks
GlobalScope Entire app lifetime Avoid — causes leaks

Summary

  • Coroutines let you write asynchronous code that reads like normal sequential code
  • suspend functions can pause without blocking the thread
  • Use launch for fire-and-forget, async when you need a result, withContext to switch threads
  • Always use viewModelScope or lifecycleScope in Android — they handle cancellation automatically
  • Use supervisorScope when parallel tasks should be independent of each other
  • Always re-throw CancellationException — never swallow it
  • Use a Job reference when you need to cancel ongoing work

Coroutines are one of the best things to happen to Android development. Once you get comfortable with them, you'll never want to go back to callbacks or threads.

Happy coding!