Advanced Kotlin Coroutine Cheat sheet (for Android Engineer)
So, you’ve been working with Kotlin Coroutines for a while and you’re already familiar with basic concepts like suspend
functions and thelaunch
builder. But as your projects become more complex, you might find yourself frequently searching for advanced solutions and asking Google or your favorite AI for help.
This cheat sheet consolidates the key insights I’ve picked up along the way. It’s designed to be a handy reference for tackling more intricate coroutine scenarios.
EDIT: I have also published a cheat sheet around flow:
Coroutine Glossary
Coroutine Context (from the Kotlin documentation): “set of various elements. The main elements are the Job of the coroutine, […] and its dispatcher”. To know more about it:
Job (from the Kotlin documentation): “cancellable thing with a life-cycle that culminates in its completion”. Every coroutine creates its own Job
(it is the only coroutine context that is not inherited from the parent coroutine). To know more about it:
Dispatcher: lets us decide which thread (or pool of threads) a coroutine should be running on (starting and resuming). To know more about it:
Coroutine scope: defines the lifetime and context of a coroutine. It is responsible for managing the lifecycle of coroutines, including their cancellation and error handling.
Coroutine builder: extensions functions on CoroutineScope
that allow us to start an asynchronous coroutine (launch
, async
, …).
Coroutines main rules
- You need a
CoroutineScope
to start a coroutine (withlaunch
orasync
for example).viewModelScope
it the one mostly used in Android but you can also build your own. - Children coroutine (a coroutine started from another coroutine) inherit their coroutine context from their parents (except the
Job
). - The
Job
of a parent coroutine is used as a parent of the new coroutine’sJob
. - A parent coroutine suspend until all of its children are finished.
- When a parent coroutine is cancelled so are all of its children.
- When a children break because of an uncaught exception, it will cancel its parent (unless you use a
SupervisorJob
, see below). - You should never use
GlobalScope
it can cause memory leaks and keep a coroutine alive even after the activity or fragment that launched it has been skipped. - You should not pass a coroutine scope as an argument instead use the
coroutineScope
function (see example below).
Coroutine scope functions
coroutineScope
: suspending function that starts a scope and returns the value produced by the argument function.
supervisorScope
: similar to coroutineScope
but it overrides the Job
of the context so the function is not cancelled when a child throw an exception.
withContext
: Similar to coroutineScope
but allow for some changes to be made in the scope (usually used to set a Dispatcher
).
withTimeout
: Similar to coroutineScope
but set a time limit for the body execution and if it is too long it will be cancelled. Throw a TimeoutCancellationException
.
withTimeoutOrNull
: Same as withTimeout
but will return a null
instead of throwing an exception when it times out.
Dispatchers
Dispatching a coroutine has a cost. When we call withContext
we need to suspend the coroutine and it might have to wait in a queue before resuming (see below how to avoid unnecessary re-dispatching).
Dispatchers.Default
- The one used by default if no dispatcher is set.
- Designed to run CPU intensive operations.
- The size of the pool of threads is equal to the number of cores on the machine.
- We can limit the number of threads an operation can use with
Dispatchers.Default.limitedParallelism(3)
.
Dispatchers.Main
- For Android run on UI thread.
- We need to be careful to no block this thread.
- Does not exist in unit tests (we need to create our own when required).
Dispatchers.IO
- Designed to run blocking operations (I/O operation, read/write files, shared preferences, etc…).
- The size of the pool of threads is 64 (or the number of cores if it’s higher than 64).
- Shared the same pool of threads as
Dispatchers.Default
but their limits are independent. - Used for blocking functions.
- To start a coroutine with this dispatcher:
withContext(Dispatchers.IO) { // some blocking function }
. - We can limit the number of threads an operation can use:
Dispatchers.Default.limitedParallelism(3)
. limitedParallelism
withDispatchers.IO
has a special behavior as it creates a new dispatcher with an independent pool of threads (limit can be higher than 64).
Dispatchers.Unconfined
- Runs on the same thread on which is was started, it does not change any threads.
- Useful for unit testing.
- Performance-wise it is the cheapest dispatcher (not thread switching).
- Dangerous to use in production code (we could accidentally run a blocking call on the main thread).
Performance observations
- When suspending the number of threads we are using doesn’t matter much.
- When blocking the more threads we are using, the faster all the coroutine will be finished.
- When doing CPU-intensive work
Dispatchers.Default
is the best option. - When doing memory-intensive work, more threads might give you better results (but it’s not significant).
To better understand how concurrency, parallelism and cancellation order, watch this great video from Dave Leeds:
Running calls in parallel
For when you want to execute two actions at the same time and wait for the result of both before returning a result:
When you have access to a scope (from a ViewModel for example)
suspend fun getConfigFromAPI(): UserConfig {
// do API call here or any suspend functions
}
suspend fun getSongsFromAPI(): List<Song> {
// do API call here or any suspend functions
}
fun getConfigAndSongs() {
// scope can be any scope you'd want a typical case would be viewModelScope
scope.launch {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
return Pair(userConfig.await(), songs.await())
}
}
Let’s say you have a paginated API and you want to download all the pages before showing them to the user. But you want to download all the pages in parallel:
suspend fun getSongsFromAPI(page: Int): List<Song> {
// do API call
}
const val totalNumberOfPages = 10
fun getAllSongs() {
// scope can be any scope you'd want a typical case would be viewModelScope
scope.launch {
val allNews = (0 until totalNumberOfPages)
.map { page -> async { getSongsFromAPI(page) } }
.awaitAll()
}
}
Note about async
/ await
. The coroutine will be started immediately when it is called. async
return an object of type Deferred<T>
(Deferred<List<Song>>
in our case). Deferred
has a suspending function await
that returns the value once it is ready.
When you don’t have access to a scope (from a repository for example)
From your repository or use case you want to define a coroutine that will start 2 (or more) calls in parallel. The problem is that you need a scope to use async
but you are not in your viewModel or presenter so you don’t have access to your scope here (remember that passing a scope as an argument is not a good solution)
From our example above:
suspend fun getConfigAndSongs(): Pair<UserConfig, List<Song> = coroutineScope {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
Pair(userConfig.await(), songs.await())
}
Cleaning when a Coroutine is cancelled
If a coroutine is cancelled then it will have the sate cancelling
before switching to cancelled
. When a coroutine is cancelling it gives us time to do some cleaning if necessary (cleaning up the local DB for example because the operation didn’t execute successfully or executing a API call to let the server knows that the operation was not successful).
We can use the finally
block to execute an operation
viewModelScope.launch {
try {
// call some suspend function here
} finally {
// execute clean up operation here
}
}
But suspension operation are not allowed during clean up. If you need to run a suspending function you will need to do
viewModelScope.launch {
try {
// call some suspend function here
} finally {
withContext(NonCancellable) {
// execute clean up suspend function here
}
}
}
Note: A cancellation will happens at the first suspension point. So cancellation will not happen if they are no suspension point in the function.
Cleaning a Coroutine when it completes
Similar to cleaning when a coroutine is cancelled you might want to execute an operation when a coroutine reaches a terminal state ( completed
or cancelled
).
suspend fun myFunction() = coroutineScope {
val job = launch { /* suspend call here */ }
job.invokeOnCompletion { exception: Throwable ->
// do something here
}
}
How to NOT cancel a Coroutine when one of its children fails
You can use a SupervisorJob
and it will ignore all exceptions in its children.
Creating your own coroutine scope
val scope = CoroutineScope(SupervisorJob())
// if one throw an error the other coroutine will not be cancelled
scope.launch { myFirstCoroutine() }
scope.launch { mySecondCoroutine() }
Using a scope function
suspend fun myFunction() = supervisorScope {
// if one throw an error the other coroutine will not be cancelled
launch { myFirstCoroutine() }
launch { mySecondCoroutine() }
}
Catching exception
suspend fun myFunction() {
try {
coroutineScope {
launch { myFirstCoroutine() }
}
} catch (e: Exception) {
// handle error here
}
try {
coroutineScope {
launch { mySecondCoroutine() }
}
} catch (e: Exception) {
// handle error here
}
}
CancellationException
do not propagate to its parent, only the current coroutine will be cancelled. It is possible to extend CancellationException
to create your own type of exception that will not propagate to the parent.
Define a default behavior in case of an exception
Use CoroutineExceptionHandler
Can be used to log out the user automatically when the server respond with a 401
for example.
val handler = CoroutineExceptionHandler { context, exception ->
// define default behaviour like showing a dialog or error message
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope. launch { /* suspend call here */ }
scope. launch { /* suspend call here */ }
Running a non-essential operation
If you want to run a suspend function that should not impact the other ones (if it throw an error for example so only this one will not cancel the parent coroutine but the other ones will).
Case example: analytics call
val nonEssentialOperationScope = CoroutineScope(SupervisorJob())
suspend fun getConfigAndSongs(): Pair<UserConfig, List<Song> = coroutineScope {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
nonEssentialOperationScope.launch { /* non essential op here */ }
Pair(userConfig.await(), songs.await())
}
Ideally you should inject the nonEssentialOperationScope
in the class (easier for testing).
Running an operation on a single thread to avoid synchronization issues
suspend fun myFunction() = withContext(Dispatchers.Default.limitedParallelism(1)) {
// suspend call here
}
// Can also use Dispatchers.IO
Other approaches to avoid synchronization issues with multithreading
You can use AtomicReference
(from Java)
private val myList = AtomicReference(listOf(/* add objects here */))
suspend fun fetchNewElement() {
val myNewElement = // fetch new element here
myList.getAndSet { it + myNewElement }
}
Or with Mutex
val mutex = Mutex()
private var myList = listOf(/* add objects here */)
suspend fun fetchNewElement() {
mutex.withLock {
val myNewElement = // fetch new element here
myList = myList += myNewElement
}
}
Avoid re-dispatching a coroutine to the same dispatcher
Avoid the unnecessary cost of switching dispatcher if we are already on the Main
dispatcher:
// this will only dispatch if it is needed
suspend fun myFunction() = withContext(Dispatcher.Main.immediate) {
// suspend call here
}
Currently only Dispatchers.Main
supports immediate
dispatching
Thanks for reading and please share your insights and pro tips about Kotlin Coroutines!
To take a deep dive on coroutines and better understand how they work, I highly recommend this book from Marcin Moskała: