Advanced Kotlin Coroutine Cheat sheet (for Android Engineer)

Gaëlle Minisini
8 min readJul 26, 2024

So, you’ve been working with Kotlin Coroutines for a while and you’re already familiar with basic concepts like suspend functions and thelaunchbuilder. 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 (with launch or async 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’s Job .
  • 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 with Dispatchers.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 401for 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:

--

--

Gaëlle Minisini

Android Engineer at Pinterest. Previously at Square. All articles are 100% me. I only use AI to help me write examples.