How to use coroutines for async tasks in Android
Android applications often need to fetch API data, read local files, query Room databases, or process images without freezing the interface. Kotlin coroutines provide a structured way to run this work asynchronously while keeping code readable and responsive. Instead of managing nested callbacks or manually handling threads, you can describe what should happen and let the coroutine framework manage suspension and resumption.
This approach suits Australian Android products ranging from Sydney public transport apps to Melbourne retail platforms and regional services that must cope with variable mobile coverage. If you arrived from a DigitalOcean Droplet showing a default phpMyAdmin setup notice, treat that page as server documentation rather than Android guidance; a focused Android learning resource can help fill the gap.
Why coroutines fit Android apps
A coroutine is a lightweight unit of work that can pause without blocking the underlying thread. When an HTTP request is waiting for a response, the coroutine suspends and allows the thread to handle other work. Once the response arrives, execution resumes from the same point, which makes asynchronous code look close to ordinary sequential code.
Coroutines are especially useful for applications that need reliable lifecycle behaviour. A request started by a screen can be cancelled when the user leaves that screen, avoiding wasted network traffic and preventing updates being sent to a destroyed Activity. This is valuable for apps used during a Sydney commute, where people frequently switch screens, lose reception in tunnels, or move between Wi-Fi and mobile data.
The key concepts are suspend functions, coroutine scopes, dispatchers, jobs, and structured concurrency. A suspend function can pause and resume, but it does not automatically run on a background thread. The dispatcher and scope determine where the work runs and how long it should remain active.
Set up a reliable coroutine scope
For UI-related work, lifecycleScope is generally the safest starting point in an Activity or Fragment. It cancels child coroutines when the associated lifecycle is destroyed. In a ViewModel, use viewModelScope, which keeps work alive through configuration changes such as screen rotation while cancelling it when the ViewModel is cleared.
class ProductsViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state
fun loadProducts() {
viewModelScope.launch {
_state.value = UiState.Loading
runCatching {
repository.fetchProducts()
}.onSuccess { products ->
_state.value = UiState.Success(products)
}.onFailure { error ->
_state.value = UiState.Error(error)
}
}
}
}
A repository should expose suspend functions rather than launching its own unmanaged global coroutines. For example, a Retrofit service can define a suspending API call, while the repository combines remote and local data. The caller then controls the scope, cancellation, and error handling. Avoid GlobalScope for application features because it can outlive screens and make failures difficult to observe.
For apps serving users across Australia, lifecycle-aware cancellation also saves data and battery. A customer in a remote Queensland town may be paying attention to their data allowance, while someone on a congested network in Brisbane benefits when obsolete requests are cancelled instead of competing with the current screen.
Choose the right dispatcher
Dispatchers indicate the type of thread pool a coroutine should use. Dispatchers.Main is for UI updates and lightweight coordination. Dispatchers.IO suits blocking input and output operations such as database access, file reads, and legacy network clients. Dispatchers.Default is intended for CPU-intensive calculations, including sorting large collections or decoding complex data.
| Task | Suitable dispatcher | Practical example |
|---|---|---|
| Update Compose or View UI | Main |
Display loading or success state |
| Room or file operation | IO |
Read cached train timetables |
| Heavy calculation | Default |
Resize or analyse many images |
| Suspending Retrofit request | Usually caller-controlled | Fetch product data from an API |
| Short coordination logic | Main |
Combine state and trigger a screen event |
Modern libraries such as Retrofit and Room support suspension, so you should not wrap every call in withContext(Dispatchers.IO) automatically. A properly implemented suspending library already avoids blocking the main thread. Use withContext at the boundary where you know blocking work occurs, and keep dispatcher decisions close to the code that owns the operation.
suspend fun loadProfile(): Profile {
val cached = withContext(Dispatchers.IO) {
profileDao.findProfile()
}
return cached ?: api.getProfile()
}
The main thread is not a place for expensive loops, large JSON transformations, or synchronous database calls. A smooth app should remain responsive on budget Android handsets as well as flagship devices commonly sold through Australian retailers such as JB Hi-Fi, Officeworks, and major telcos.
Handle results, errors, and cancellation
Coroutine exceptions should be handled at an appropriate boundary. A try/catch block around a repository call works well when the caller needs to convert an exception into a UI state. runCatching can make simple success and failure paths concise, but be careful not to swallow CancellationException. Cancellation is a normal control signal and should generally be allowed to propagate.
viewModelScope.launch {
try {
val result = withTimeout(8_000) {
repository.fetchOrders()
}
_state.value = UiState.Success(result)
} catch (error: IOException) {
_state.value = UiState.Error("Check your connection")
} catch (error: TimeoutCancellationException) {
_state.value = UiState.Error("The request took too long")
}
}
Use withTimeout or withTimeoutOrNull when a request should not wait indefinitely. This is useful for a live delivery estimate, a payment status check, or a weather refresh during Australian summer storms, when network conditions can change quickly. For retries, use a limited number of attempts with a delay rather than retrying forever.
Structured concurrency keeps parent and child jobs connected. coroutineScope fails when a child fails, while supervisorScope allows sibling operations to continue independently. For a dashboard loading weather, account details, and notifications, a supervisor may be appropriate if one optional panel can fail without hiding the others.
Build a practical coroutine workflow
A dependable Android architecture usually follows this path: the UI sends an event to a ViewModel, the ViewModel launches work in its lifecycle scope, the repository coordinates data sources, and the UI observes a StateFlow. This separation keeps network details away from composables and makes the code easier to test.
For Kotlin Flow, collect from a lifecycle-aware context. In Jetpack Compose, collectAsStateWithLifecycle() prevents collection when the screen is not active. In a Fragment, repeatOnLifecycle starts and stops collection according to the visible lifecycle state. These patterns reduce unnecessary work when users move between tabs during EOFY shopping or while comparing plans from different Australian providers.
Practical habits that make coroutine code safer include:
- Prefer
viewModelScopeandlifecycleScopeover unbounded application scopes. - Keep suspend functions small, focused, and free from UI responsibilities.
- Move blocking database, file, and legacy client calls to an appropriate dispatcher.
- Represent loading, success, empty, and error states explicitly.
- Test cancellation, timeouts, retries, and offline behaviour with controlled dispatchers.
Testing should verify observable outcomes rather than waiting for real time. The Kotlin coroutine test library provides test dispatchers and virtual time, allowing a test to advance delays instantly. Mock the repository for ViewModel tests, then use instrumented or integration tests for Room, Retrofit, and lifecycle behaviour.
Start by converting one callback-heavy operation, such as loading a product list or refreshing a train timetable, into a suspend function. Add a lifecycle-aware scope, expose a clear UI state, and test cancellation before expanding the pattern across the application. This gives your Android project a responsive foundation suited to both metropolitan users and customers relying on slower regional connections.