I didn't know my app was broken.
It worked. The data showed up. The UI updated. I thought I was doing great.
Then someone ran it on a slow connection and sent me a screen recording. The app was completely frozen for 3 seconds. Nothing moved. The button didn't respond. It looked dead.
I was doing network calls on the Main Thread. I had no idea that was even a thing I could do wrong.
That's when I started actually learning coroutines — not copy-pasting them, learning them.
First, Why Does the Main Thread Even Matter?
Your Android app runs on one main thread. That thread does everything you see — drawing buttons, responding to taps, updating text. It's the only thread that can touch the UI.
If you give it slow work to do — like waiting for an API response — it can't do anything else. It just stands there. Waiting. While your users stare at a frozen screen.
Think of it like a waiter who goes to the kitchen, stands there until the food is ready, and refuses to take any other orders in the meantime. The whole restaurant stops.
Main Thread: [waiting for network response.........] [show result]
☠️ App is completely frozen here
That's the problem coroutines solve.
So What Even Is a Coroutine?
A coroutine is a piece of work that can pause itself without blocking the thread.
The waiter analogy again — but this time, the waiter puts in the order, goes back to serving other tables, and comes back when the food is ready. The restaurant keeps running. Nobody is waiting.
Coroutine: [start] → [pause: waiting for network] → [resume] → [done]
Main Thread: ↑ free to handle UI here ↑
The keyword that makes this possible is suspend.
suspend — The One Keyword That Changes Everything
A suspend function is a function that can pause and resume. That's the whole thing.
suspend fun getPokemon(): Pokemon {
return api.fetchPokemon("pikachu") // pauses here, frees the thread
}
It looks exactly like a normal function. It returns a value like a normal function. The difference is what happens underneath — instead of blocking the thread, it pauses and lets the thread go do other things.
Two rules to remember:
- You can only call a suspend function from another suspend function, or from inside a coroutine.
- That's it. That's the rule.
Starting a Coroutine — launch vs async
You can't just call a suspend function from anywhere. You need to launch a coroutine first. Think of it as opening a room where suspend functions are allowed to live.
launch — when you don't need a result back
viewModelScope.launch {
val pokemon = getPokemon() // suspend call — pauses here
_uiState.value = pokemon // resumes here when done
}
async — when you need a result
viewModelScope.launch {
val pokemonDeferred = async { getPokemon() }
val pokemon = pokemonDeferred.await() // wait for the result
}
Most of the time you'll use launch. Use async when you want to run two things at the same time and wait for both.
Dispatchers — Who Does the Work?
Just because you're in a coroutine doesn't mean the slow work goes to the right thread automatically. You have to tell it where to run. That's what Dispatchers are for.
| Dispatcher | Use it for |
|---|---|
Dispatchers.Main |
UI updates — text, buttons, anything visible |
Dispatchers.IO |
Network calls, database reads, file access |
Dispatchers.Default |
Heavy CPU work — sorting, parsing large data |
viewModelScope.launch(Dispatchers.IO) { // network work on IO
val pokemon = api.fetchPokemon("pikachu")
withContext(Dispatchers.Main) { // switch to Main for UI
textView.text = pokemon.name
}
}
withContext is how you switch dispatchers mid-coroutine. It's clean. It's readable. You'll use it constantly.
Scopes — The Lifetime of a Coroutine
Every coroutine lives inside a scope. When the scope dies, all its coroutines die too. This is how Android prevents memory leaks automatically.
| Scope | Dies when |
|---|---|
viewModelScope |
The ViewModel is cleared |
lifecycleScope |
The Activity or Fragment is destroyed |
GlobalScope |
Never — avoid this one |
As a junior dev, you'll mostly use viewModelScope. It's the safe default.
class PokemonViewModel : ViewModel() {
fun loadPokemon(name: String) {
viewModelScope.launch { // auto-cancelled when ViewModel dies
val pokemon = repository.getPokemon(name)
_pokemon.value = pokemon
}
}
}
You didn't write any cleanup code. You didn't manage any threads manually. The scope handled all of it.
Error Handling
One more thing before you go build something — handle your errors.
viewModelScope.launch {
try {
val pokemon = api.fetchPokemon(name)
_uiState.value = UiState.Success(pokemon)
} catch (e: Exception) {
_uiState.value = UiState.Error("Something went wrong")
}
}
Network calls fail. Always wrap them.
The Mental Model
This is the whole thing in one picture:
viewModelScope.launch(Dispatchers.IO) {
│
├── call suspend fun → coroutine PAUSES, thread goes free
│
├── response arrives → coroutine RESUMES
│
└── withContext(Dispatchers.Main) { update the UI }
}
- Scope controls the lifetime.
- Dispatcher controls the thread.
-
suspendcontrols the pause.
What I Wish I Knew Earlier
Coroutines aren't magic. They're a structured way to write async code that looks synchronous. No callbacks. No nested hell. Just top-to-bottom code that knows when to pause and when to come back.
Once that clicked for me, a lot of the "why does this feel wrong" in my code started making sense.
If your app ever froze and you didn't know why — now you do.
Top comments (0)