When we audit an older Android app, the same three coroutine bugs show up almost every time. A GlobalScope.launch that keeps uploading after the user has logged out. A Flow collected in onCreate that keeps running while the app sits in the background, draining battery. And a try/catch around a network call that quietly swallows cancellation, so the screen updates after it has already closed. None of these are exotic. They come from learning coroutines piece by piece from Stack Overflow answers. This guide is the set of rules we teach so a team writes them the same way.
Suspend Functions and Dispatchers
A suspend function can pause without blocking the thread it runs on. That is the whole trick. When a repository calls a Retrofit API declared as suspend, the main thread is free to draw frames while the request is in flight. Retrofit and Room both support suspend functions directly and handle their own threading, so calling them from the main thread is safe.
Your own blocking code is a different story. Reading a large file, hashing a password, or parsing a big JSON with a library that is not coroutine aware all need withContext. Use Dispatchers.IO for blocking I/O. It allows up to 64 threads by default, or more on devices with more cores. Use Dispatchers.Default for CPU work like sorting or image resizing, which has one thread per core. The rule we follow is that every suspend function must be main-safe. The function that does the blocking work switches dispatchers itself, so callers never have to think about it.
Inject dispatchers through the constructor instead of hardcoding Dispatchers.IO everywhere. It costs one parameter and makes testing much simpler later.
Scopes and Structured Concurrency
Every coroutine should belong to a scope that matches how long the work should live. On Android you rarely need to create one yourself.
| Scope | Lives until | Use it for |
|---|---|---|
| viewModelScope | The ViewModel is cleared | Loading screen data, submitting forms, most UI-driven work |
| lifecycleScope | The Activity or Fragment is destroyed | UI-only work such as animations, or starting repeatOnLifecycle |
| An application scope you inject | The process dies | Work that must finish even if the user leaves the screen, like saving a draft |
| WorkManager | The work completes, even across restarts | Uploads, sync, anything that must survive the process being killed |
| GlobalScope | Never cancelled | Almost nothing, which is why it is marked as a delicate API |
Structured concurrency means a parent waits for its children and cancelling the parent cancels them all. If you open coroutineScope and launch three requests inside it, the function only returns when all three finish, and if one fails the other two are cancelled. When you want siblings to keep going after one fails, such as loading three independent dashboard widgets, use supervisorScope instead.
Cancellation Is Cooperative
Cancellation works by throwing CancellationException at the next suspension point. Two habits break it. The first is catching Exception or Throwable around suspend calls and not rethrowing CancellationException. The second is runCatching around a suspend call, which catches everything including cancellation. Either way the coroutine keeps going after its scope is gone. Catch the specific exceptions you expect, such as IOException or HttpException, or rethrow CancellationException explicitly. In long loops that never suspend, call ensureActive() so the loop stops when asked.
Flow, StateFlow, and SharedFlow
- Flow is cold. Nothing runs until someone collects it, and each collector gets its own run. Room queries returning Flow and repository functions belong here.
- StateFlow is hot and always has a current value. New collectors get the latest value immediately, and equal values in a row are skipped. Use it for screen state in a ViewModel.
- SharedFlow is hot with no required value. You choose how many past values to replay and how to buffer. It fits broadcast events that several parts of the app listen to.
To turn a cold Flow from Room into state for a screen, use stateIn with SharingStarted.WhileSubscribed(5000). The five second window keeps the upstream alive through a rotation, then stops it when the user really leaves. For one-off events like showing a snackbar or navigating, a StateFlow will replay the event after rotation. Many teams use a Channel exposed with receiveAsFlow for these. Google now recommends modelling them as part of the UI state and clearing them once handled, which is more work but cannot lose an event.
Collect Flows With the Lifecycle
Calling collect inside lifecycleScope.launch keeps collecting while the app is in the background. With Room or location updates this means real work and real battery use. In View-based screens, wrap collection in repeatOnLifecycle(Lifecycle.State.STARTED). It starts collecting when the screen becomes visible and cancels when it goes to the background. In Compose, use collectAsStateWithLifecycle from lifecycle-runtime-compose rather than plain collectAsState, which keeps collecting regardless of the lifecycle.
Handling Errors
- 1In the repository, convert expected failures like timeouts and HTTP 4xx into a result type your UI understands, so the ViewModel deals with data instead of exceptions.
- 2In Flow chains, use the catch operator to emit a fallback or error state. It only handles errors from upstream, so put it after the operators that can fail.
- 3Use retry or retryWhen with a backoff for flaky network calls, and cap the number of attempts.
- 4Install a CoroutineExceptionHandler only on root coroutines started with launch, for logging to Crashlytics or Sentry. It does nothing on async or child coroutines.
Testing With runTest
The kotlinx-coroutines-test library gives you runTest, which runs on virtual time. A delay(30_000) completes instantly, so testing a debounce or a retry with backoff takes milliseconds. Replace the main dispatcher with Dispatchers.setMain in a small JUnit rule, and pass a test dispatcher into the classes that take a dispatcher parameter. StandardTestDispatcher queues work until you advance it, which is good for checking intermediate states. UnconfinedTestDispatcher runs work eagerly, which keeps simple tests short. For Flows, the Turbine library from Cash App lets you call awaitItem() and assert each emission in order, and it catches unexpected extra emissions too.
Mistakes We Still See
- runBlocking on the main thread. It freezes the UI, and after about five seconds without handling input Android shows an ANR dialog.
- Launching from a ViewModel into GlobalScope to "make sure it finishes". Use an injected application scope or WorkManager.
- Calling a blocking SDK inside a suspend function without withContext, which looks safe in code review and still blocks the main thread.
- Exposing MutableStateFlow from the ViewModel so the UI can change state directly. Expose StateFlow and keep the mutable one private.
- Using flowOn and expecting it to change where collect runs. It only affects the operators above it.
If you cannot say which scope will cancel a coroutine, you have written a leak that has not happened yet.
Coroutines and Flow take a few days to learn and a few months to use well. Our Android Kotlin training spends a full module on them, using the team's own screens as exercises, so the rules above end up in the codebase rather than only in the slides.
Key takeaways
- Make every suspend function main-safe by switching to Dispatchers.IO or Default inside the function that blocks.
- Use viewModelScope and lifecycleScope for UI work, an injected application scope or WorkManager for longer work, and avoid GlobalScope.
- Never swallow CancellationException with a broad catch or runCatching around suspend calls.
- Expose screen state as StateFlow with stateIn and WhileSubscribed(5000), and collect it with repeatOnLifecycle or collectAsStateWithLifecycle.
- Test with runTest and injected dispatchers, and use Turbine to check Flow emissions in order.


