Working with suspend Functions and Coroutine Builders
Course: Android App Development
Topic: Working with suspend Functions and Coroutine Builders
Course Link: Android App Development Course
Demo Link: Register for a Course Demo
Website: JustAcademy
1. Introduction to Kotlin Coroutines
Modern Android applications frequently perform operations that take time, such as fetching information from REST APIs, reading data from a database, loading files, and processing large amounts of information. If these operations block the main thread, the application can become unresponsive and may display an Application Not Responding (ANR) dialog.
Kotlin coroutines provide a way to write asynchronous and concurrent code in a clear, sequential style. A coroutine can suspend its execution while waiting for an operation to complete, allowing the underlying thread to perform other work.
Two important concepts in coroutine-based programming are:
- Suspend functions: Functions marked with the
suspend keyword that can pause and resume coroutine execution.
- Coroutine builders: Functions such as
launch, async, and runBlocking that create or start coroutine execution in a scope.
In Android, coroutines are commonly used with ViewModels, repositories, Retrofit, Room, and Jetpack Compose to manage asynchronous tasks while keeping the interface responsive.
2. Learning Objectives
- Understand the purpose of Kotlin coroutines.
- Learn how suspend functions work.
- Understand the difference between suspending and blocking.
- Use coroutine builders such as
launch, async, and runBlocking.
- Understand
withContext and coroutineScope.
- Learn about coroutine scopes and dispatchers.
- Execute sequential and concurrent operations.
- Handle coroutine exceptions and cancellation.
- Integrate coroutines with Android ViewModels and REST APIs.
- Apply structured concurrency and coroutine best practices.
3. What Is a Suspend Function?
A suspend function is a Kotlin function declared using the suspend keyword. It can suspend the current coroutine at suspension points and resume it later when the required operation is ready.
A suspend function does not automatically create a new coroutine, run on a background thread, or make blocking code non-blocking. It simply allows the function to participate in coroutine suspension.
3.1 Basic Syntax
suspend fun loadMessage(): String {
return "Hello from a suspend function"
}
This function returns a string. It is marked as suspending, but it does not actually suspend because it contains no suspension point.
3.2 Calling a Suspend Function
A suspend function can normally be called from another suspend function or from a coroutine builder such as launch or async.
import kotlinx.coroutines.delay
suspend fun loadMessage(): String {
delay(1000L)
return "Message loaded successfully"
}
suspend fun displayMessage() {
val message = loadMessage()
println(message)
}
The delay() function suspends the coroutine for approximately one second without blocking the thread. The execution of displayMessage() continues after the delay completes.
3.3 Calling a Suspend Function from a Coroutine
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.delay
suspend fun fetchUserName(): String {
delay(1000L)
return "Aarav"
}
fun main() = runBlocking {
launch {
val name = fetchUserName()
println("User: $name")
}
}
Here, runBlocking creates a coroutine scope and blocks the calling thread until its coroutine and child coroutines complete. The launch builder starts a child coroutine, which calls the suspend function.
Expected output:
User: Aarav
Important: Use runBlocking for suitable command-line examples and tests, not for ordinary Android UI event handlers. Blocking the Android main thread can freeze the interface.
4. Suspending vs. Blocking
Suspending and blocking are different concepts. A blocking operation prevents the current thread from doing other work until the operation completes. A suspending operation pauses the coroutine while allowing the thread to be used by other work.
| Feature |
Suspending |
Blocking |
| Thread behavior |
The coroutine pauses; the thread can be reused. |
The current thread waits and cannot perform other work. |
| Typical example |
delay(1000L) |
Thread.sleep(1000L) |
| UI responsiveness |
Can help keep the UI responsive when used correctly. |
Can freeze the UI if used on the main thread. |
| Coroutine required |
Suspension must occur in a valid coroutine context. |
Can be called from ordinary synchronous code. |
| Background execution |
Not guaranteed just because a function is suspend. |
Depends on the thread where the blocking call runs. |
Example of blocking code:
fun blockingTask() {
Thread.sleep(2000L)
println("Blocking task finished")
}
Example of suspending code:
suspend fun suspendingTask() {
delay(2000L)
println("Suspending task finished")
}
Use suspend-friendly APIs whenever possible. If a suspend function calls a genuinely blocking API, move that blocking work to an appropriate dispatcher, such as Dispatchers.IO.
5. What Is a Coroutine Builder?
A coroutine builder is a function that starts coroutine execution or creates a coroutine scope for executing suspending code. Common builders and scope functions include:
| Function |
Main Purpose |
Returns |
launch |
Starts a coroutine when its result is not needed directly. |
Job |
async |
Starts a coroutine that produces a result. |
Deferred<T> |
runBlocking |
Bridges blocking code to coroutine code. |
The result of its block |
withContext |
Switches coroutine context and returns a result. |
The block result |
coroutineScope |
Creates a structured child scope and waits for its children. |
The block result |
Although withContext and coroutineScope are often taught alongside coroutine builders, their main role is to change context or establish structured scopes within existing suspending execution.
6. Using the launch Builder
The launch builder starts a coroutine that performs work without returning a computed value directly. It returns a Job, which can be used to observe completion or cancel the coroutine.
6.1 Basic Example
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(1000L)
println("Task completed")
}
println("Main coroutine continues")
}
Possible output:
Main coroutine continues
Task completed
The parent coroutine can continue while the launched child is suspended. However, the outer runBlocking scope waits for its child coroutines before completing.
6.2 Store a Job
val job = launch {
delay(2000L)
println("Background operation completed")
}
job.join()
println("The job has completed")
The join() function suspends the caller until the job completes. It does not block the thread while waiting.
6.3 Cancel a Coroutine
val job = launch {
repeat(10) { index ->
delay(500L)
println("Step: $index")
}
}
delay(1200L)
job.cancel()
job.join()
println("Coroutine cancelled")
The job is cancelled after approximately 1.2 seconds. Because delay() is cancellable, the coroutine can stop promptly rather than continue all ten steps.
6.4 When to Use launch
- Loading data in a ViewModel.
- Saving information to a database.
- Performing a background operation whose result is communicated through state.
- Running several child tasks within a structured scope.
- Sending a request and updating a StateFlow or UI state when it completes.
7. Using the async Builder
The async builder starts a coroutine that produces a result. It returns a Deferred<T>, which represents a value that will be available later.
Call await() to obtain the result. If the result is not ready, await() suspends the calling coroutine until the value is available.
7.1 Basic Example
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val result = async {
delay(1000L)
100
}
println("Result: ${result.await()}")
}
Output:
Result: 100
7.2 Running Two Tasks Concurrently
Suppose an Android screen needs user information and a list of notifications. If the two requests are independent, they can run concurrently.
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
suspend fun fetchUser(): String {
delay(1000L)
return "User details"
}
suspend fun fetchNotifications(): String {
delay(1200L)
return "Notification list"
}
suspend fun loadDashboard(): Pair<String, String> =
coroutineScope {
val user = async {
fetchUser()
}
val notifications = async {
fetchNotifications()
}
Pair(
user.await(),
notifications.await()
)
}
Both operations begin without waiting for the other to finish. The total waiting time is approximately the duration of the slower operation, rather than the sum of both delays, assuming the underlying operations genuinely overlap.
7.3 When to Use async
- Fetching two independent API resources concurrently.
- Computing a result that will be needed later.
- Combining results from multiple independent suspending operations.
Do not use async automatically for every task. If the result is not needed, launch is usually more appropriate. Prefer structured concurrency so that child tasks are properly awaited and cancelled with their parent.
8. Understanding runBlocking
The runBlocking builder starts a coroutine and blocks the current thread until its block and child coroutines complete. It is mainly useful at synchronous boundaries, such as small command-line examples and certain tests.
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
println("Started")
delay(1000L)
println("Finished")
}
Output:
Started
Finished
Although delay() suspends the coroutine, the outer runBlocking still blocks the calling thread until its coroutine completes.
Avoid this in Android UI code:
// Avoid calling this from an Activity or composable click handler.
runBlocking {
loadRemoteData()
}
Blocking the main thread can cause frozen animations, delayed input, and ANR errors. Use an appropriate lifecycle-aware coroutine scope instead.
9. Using withContext
The withContext function executes a suspending block with a specified coroutine context and returns the block's result. It is frequently used to move blocking work away from the main thread.
9.1 Basic Example
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun calculateReport(): String {
return withContext(Dispatchers.Default) {
val total = (1..1_000_000).sum()
"Total: $total"
}
}
Dispatchers.Default is suitable for CPU-intensive work. For blocking file or database APIs that do not already provide safe asynchronous operations, Dispatchers.IO is generally the more suitable choice.
9.2 Move Blocking I/O to Dispatchers.IO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun readTextFile(file: File): String =
withContext(Dispatchers.IO) {
file.readText()
}
This example moves the blocking file read to an I/O dispatcher. In a real Android app, use the correct storage APIs and avoid performing unrestricted file access outside the application's permissions and storage rules.
9.3 Choosing a Dispatcher
| Dispatcher |
Typical Use |
Dispatchers.Main |
UI-related work and quick state updates on the Android main thread. |
Dispatchers.IO |
Blocking I/O operations such as file access or legacy blocking database calls. |
Dispatchers.Default |
CPU-intensive work such as large computations or data processing. |
Dispatchers.Unconfined |
Specialized coroutine execution behavior; rarely appropriate as a default for application code. |
Retrofit's suspend-based network calls and Room's suspend DAO functions already provide appropriate asynchronous integration. Do not add dispatcher switching automatically without understanding the API's threading behavior.
10. Using coroutineScope
The coroutineScope function creates a child scope within a suspending function. It does not return until its block and all child coroutines complete. It is useful when a function needs to start multiple related tasks and return only after they finish.
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
suspend fun loadScreenData(): List<String> =
coroutineScope {
val first = async {
delay(500L)
"Profile"
}
val second = async {
delay(700L)
"Messages"
}
listOf(first.await(), second.await())
}
The function returns a list containing the two results. Because the tasks are children of the same scope, their lifecycles are related to the scope's completion and cancellation.
10.1 coroutineScope vs. launch
launch starts a child coroutine and returns a Job handle. coroutineScope suspends the caller until the scope's block and child tasks complete and returns the block's result.
11. Sequential and Concurrent Execution
Understanding execution order is essential when combining suspend functions.
11.1 Sequential Execution
suspend fun loadProfile(): String {
val user = fetchUser()
val notifications = fetchNotifications()
return "$user + $notifications"
}
Here, the second function is called only after the first function returns. This is appropriate when the second operation depends on the first or when concurrent execution is unnecessary.
11.2 Concurrent Execution
suspend fun loadProfileConcurrently(): Pair<String, String> =
coroutineScope {
val user = async { fetchUser() }
val notifications = async { fetchNotifications() }
Pair(user.await(), notifications.await())
}
This version starts both independent tasks without waiting for the first one to finish. Use concurrency when operations can safely run independently.
| Sequential |
Concurrent |
| Each operation starts after the previous one returns. |
Independent operations can overlap. |
| Often easier to understand for dependent operations. |
Useful for independent network calls or computations. |
| Total time may approach the sum of task durations. |
Total waiting time may approach the duration of the slowest task. |
| Simple control flow. |
Requires careful result and failure handling. |
12. Coroutine Scopes in Android
A coroutine scope defines the lifecycle and context for coroutines launched within it. In Android, choosing the correct scope helps prevent work from continuing after the relevant screen or component is no longer active.
| Scope |
Typical Usage |
Lifecycle |
viewModelScope |
Loading data for a ViewModel. |
Cancelled when the ViewModel is cleared. |
lifecycleScope |
Lifecycle-bound work in an Activity or Fragment. |
Cancelled when the associated Lifecycle is destroyed. |
rememberCoroutineScope() |
Launching work from a Jetpack Compose event handler. |
Cancelled when the associated composable leaves the composition. |
CoroutineScope |
Custom scopes for clearly defined ownership and lifecycle. |
Depends on the owner and cancellation strategy. |
Use lifecycle-aware scopes rather than creating unmanaged global coroutines for ordinary screen operations. For work that must continue beyond a screen or app process lifecycle, select an appropriate architecture such as WorkManager for deferrable persistent background work.
13. Using Suspend Functions with ViewModel
A common Android pattern is to call a suspend repository function from a coroutine launched in viewModelScope.
13.1 Repository
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(id: Int): User {
return api.getUser(id)
}
}
This example assumes that UserApi is a Retrofit interface and that User is a matching model.
13.2 UI State
sealed interface UserUiState {
data object Loading : UserUiState
data class Success(val user: User) : UserUiState
data class Error(val message: String) : UserUiState
}
13.3 ViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _uiState =
MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> =
_uiState.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
try {
val user = repository.getUser(id)
_uiState.value = UserUiState.Success(user)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.value =
UserUiState.Error("Network connection failed.")
} catch (e: HttpException) {
_uiState.value =
UserUiState.Error("HTTP error: ${e.code()}")
} catch (e: Exception) {
_uiState.value =
UserUiState.Error("Unable to load user.")
}
}
}
}
This example illustrates the flow of data. In a production application, consider structured logging, dependency injection, domain-specific error types, and tests for failure conditions. Cancellation exceptions should normally be rethrown rather than treated as regular failures.
14. Error Handling in Coroutines
Network requests, file operations, and database operations can fail. Coroutines use normal Kotlin exception handling, but the way exceptions propagate depends on the coroutine builder and scope hierarchy.
14.1 Using try-catch
suspend fun loadDataSafely(): String {
return try {
fetchRemoteData()
} catch (e: IOException) {
"Network unavailable"
}
}
The example assumes that fetchRemoteData() may throw an IOException. Returning a display message is useful for a simple demonstration, but production repositories should usually distinguish actual data from error states rather than return an error message as if it were successful data.
14.2 CancellationException
Cancellation is part of structured concurrency. If a coroutine is cancelled, suspend functions that support cancellation can stop the operation. Avoid swallowing CancellationException in broad exception handlers.
try {
performOperation()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
handleFailure(e)
}
14.3 Exceptions with launch and async
launch does not return a result value; an uncaught exception can propagate through its coroutine hierarchy.
async stores a successful result or failure in its Deferred; the failure is observed when awaiting the result, and parent-child exception rules still apply.
supervisorScope can be useful when sibling tasks should fail independently, but failures still need to be handled deliberately.
15. Coroutine Cancellation and Timeouts
Cancellation prevents unnecessary work from continuing after it is no longer needed. It is particularly important for search requests, navigation changes, and screen-bound API calls.
15.1 Cancellation
val job = launch {
repeat(5) { index ->
delay(1000L)
println("Loading item $index")
}
}
job.cancelAndJoin()
cancelAndJoin() requests cancellation and waits for the coroutine to finish. It is useful when a caller needs to ensure that a task has stopped before proceeding.
15.2 Timeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
suspend fun loadWithTimeout(): String {
return withTimeout(3000L) {
fetchRemoteData()
}
}
If the operation does not complete within the timeout, withTimeout cancels its block and throws a timeout cancellation exception. The caller should handle timeout behavior at the appropriate layer.
16. Common Mistakes and Their Solutions
| Mistake |
Why It Is a Problem |
Solution |
| Calling a suspend function from ordinary synchronous code without a coroutine entry point |
The code will not compile in a normal non-suspending context. |
Call it from a suspend function or a suitable coroutine builder. |
| Assuming every suspend function runs on a background thread |
The suspend modifier does not choose a dispatcher. |
Use an appropriate dispatcher for blocking or CPU-intensive work. |
| Using runBlocking on the main thread |
It blocks the UI thread. |
Use lifecycle-aware coroutine scopes. |
| Using async when no result is needed |
It adds unnecessary result-handling complexity. |
Use launch for work that does not return a result directly. |
| Using launch when a value must be returned directly |
Launch returns a Job, not the result of its block. |
Use async and await for concurrent result-producing work when appropriate. |
| Creating unmanaged global coroutines |
Work may outlive its owner and become difficult to cancel. |
Use structured concurrency and an appropriate scope. |
| Swallowing CancellationException |
Cancelled operations may continue or behave incorrectly. |
Rethrow cancellation exceptions when catching broader exceptions. |
| Switching dispatchers without a reason |
It can make execution flow harder to understand. |
Use dispatcher switching when it is needed for blocking or CPU-heavy work. |
17. Practical Exercise
Exercise: Load User Details Asynchronously
Build a small Android screen that loads user details from a REST API using suspend functions and coroutine builders.
Requirements
- Create a Retrofit API interface with a suspend function.
- Create a repository that calls the API.
- Use
viewModelScope.launch to load the data.
- Expose loading, success, and error states through a StateFlow.
- Display the result in a Jetpack Compose screen.
- Add a retry button for failed requests.
- Handle HTTP errors and network failures.
- Verify that cancelling the relevant scope stops unnecessary work.
Extension Exercise
Extend the screen so it retrieves a user profile and a list of posts at the same time. Use async inside coroutineScope only if the two requests are independent. Display both results when they have completed successfully.
18. Mini Project: Coroutine-Based Dashboard
Create a dashboard screen that loads several independent resources, such as a user profile, notifications, and recent posts.
Features:
- Use suspend functions for repository methods.
- Use
launch for a screen-loading operation that updates UI state.
- Use
async to load independent resources concurrently.
- Use
withContext for genuine blocking work that must run off the main thread.
- Use a ViewModel scope to control the screen's data-loading lifecycle.
- Handle failures and cancellation safely.
- Show loading indicators and retry controls.
- Write unit tests using coroutine test utilities and fake repositories.
Expected result: The dashboard should load data without freezing the UI and should handle cancellation and errors in a predictable way.
19. Interview Questions and Answers
Q1. What is a Kotlin coroutine?
Answer: A coroutine is a suspendable computation that can pause and resume without necessarily blocking the thread while it waits.
Q2. What is a suspend function?
Answer: A function marked with the suspend keyword that can participate in coroutine suspension and call other suspending functions.
Q3. Does a suspend function automatically run on a background thread?
Answer: No. The suspend modifier allows suspension but does not automatically change the execution dispatcher.
Q4. What is a coroutine builder?
Answer: It is a function that starts coroutine execution or establishes a coroutine scope, such as launch, async, or runBlocking.
Q5. What is the difference between launch and async?
Answer: Launch returns a Job and is used when a result value is not needed directly. Async returns a Deferred result that can be obtained using await().
Q6. What is Deferred?
Answer: Deferred is a Job-like coroutine result holder that represents a value that may become available later.
Q7. What does await() do?
Answer: It suspends the calling coroutine until a Deferred result is available and returns the result or propagates its failure.
Q8. What is runBlocking?
Answer: It starts a coroutine and blocks the current thread until its block and child coroutines complete. It is generally unsuitable for normal Android UI code.
Q9. What is withContext?
Answer: It runs a suspending block with a specified coroutine context and returns the result, commonly for dispatcher changes.
Q10. What is coroutineScope?
Answer: It creates a structured child scope and suspends until its block and child coroutines complete.
Q11. What is the difference between suspending and blocking?
Answer: Suspending pauses a coroutine while allowing the thread to do other work. Blocking prevents the current thread from proceeding until the operation finishes.
Q12. What is Dispatchers.IO used for?
Answer: It is optimized for blocking input/output operations, such as file operations or legacy blocking database calls.
Q13. What is Dispatchers.Default used for?
Answer: It is intended for CPU-intensive work, such as computation and data processing.
Q14. Why is viewModelScope useful?
Answer: It launches coroutines associated with a ViewModel and automatically cancels them when that ViewModel is cleared.
Q15. Why should CancellationException usually be rethrown?
Answer: Cancellation is a control mechanism in structured concurrency. Swallowing the exception can prevent cancellation from propagating correctly.
Q16. Can async improve performance?
Answer: It can reduce waiting time when independent tasks can run concurrently. It does not automatically make every operation faster, and concurrency introduces additional considerations.
Q17. What is structured concurrency?
Answer: It is an approach in which related coroutines have clear parent-child relationships and their lifecycles, cancellation, and completion are managed together.
Q18. How are coroutines used with Retrofit?
Answer: Retrofit supports suspend API methods, allowing network requests to be called from coroutines without blocking the caller while the response is pending.
20. Quick Revision Checklist
- The
suspend keyword allows a function to participate in coroutine suspension.
- A suspend function does not automatically switch threads.
launch returns a Job.
async returns a Deferred result.
await() suspends until the result is available.
runBlocking blocks the calling thread and should not be used for ordinary UI work.
withContext changes coroutine context and returns a result.
coroutineScope waits for its block and child coroutines to finish.
Dispatchers.IO is suitable for blocking I/O.
Dispatchers.Default is suitable for CPU-intensive tasks.
viewModelScope manages ViewModel-related coroutine lifecycles.
- Structured concurrency helps manage cancellation and child tasks.
- Handle failures carefully and do not swallow cancellation exceptions.
21. Official Learning Resources
22. Conclusion
Suspend functions and coroutine builders are essential tools for modern Android development. Suspend functions let asynchronous operations be expressed in a readable sequential style, while builders such as launch and async provide ways to start coroutine work and manage results.
Understanding runBlocking, withContext, coroutine scopes, dispatchers, cancellation, and structured concurrency helps developers build responsive applications that handle network requests and other long-running tasks safely.
To continue learning, explore the JustAcademy Android App Development Course, register for a course demo, or visit the JustAcademy website.