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
suspendfunctions can pause without blocking the thread- Use
launchfor fire-and-forget,asyncwhen you need a result,withContextto switch threads - Always use
viewModelScopeorlifecycleScopein Android — they handle cancellation automatically - Use
supervisorScopewhen parallel tasks should be independent of each other - Always re-throw
CancellationException— never swallow it - Use a
Jobreference 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!
Comments (0)